From 57e0ea8f75efe8d1ffeb1cb1d8f2b6c6add82062 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 12 Jul 2026 07:25:04 +0530 Subject: [PATCH] fix(auto-reply): honor structured FailoverError reasons in terminal catch classification context_overflow and transient (timeout/server_error/overloaded) FailoverError reasons now classify structurally like billing/rate_limit already do, instead of relying on message text that may lack overflow or HTTP-status tokens. A typed transient failure without a leading status token now takes the single transient retry; typed context overflow suppresses the generic failure reply. Part of #104219 (row 12). --- .../reply/agent-runner-execution.test.ts | 77 +++++++++++++++++++ .../reply/agent-runner-execution.ts | 15 +++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index c742f7310243..27757c1ffeaf 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1401,6 +1401,8 @@ describe("runAgentTurnWithFallback", () => { }); afterEach(() => { + // Fake-timer tests in this describe must not leak into --isolate=false peers. + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -7856,6 +7858,81 @@ describe("runAgentTurnWithFallback", () => { expect(state.updateSessionStoreMock).not.toHaveBeenCalled(); }); + it.each([ + { + reason: "server_error" as const, + message: "upstream provider failed briefly", + }, + { + reason: "timeout" as const, + message: "provider request timed out without status token", + }, + { + reason: "overloaded" as const, + message: "provider capacity exhausted briefly", + }, + ])( + "retries once for structured FailoverError $reason without leading HTTP status text", + async ({ reason, message }) => { + vi.useFakeTimers(); + state.runEmbeddedAgentMock + .mockRejectedValueOnce( + new FailoverError(message, { + reason, + provider: "openai", + model: "gpt-5.5", + }), + ) + .mockResolvedValueOnce({ + payloads: [{ text: "recovered after transient failover" }], + meta: {}, + }); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const resultPromise = runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + await vi.advanceTimersByTimeAsync(2_500); + const result = await resultPromise; + + expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(2); + expect(result.kind).toBe("success"); + if (result.kind === "success") { + expect(result.runResult.payloads?.[0]?.text).toBe("recovered after transient failover"); + } + }, + ); + + it("uses structured FailoverError context_overflow over non-overflow message text", async () => { + state.isLikelyContextOverflowErrorMock.mockReturnValue(false); + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError("provider rejected the request payload", { + reason: "context_overflow", + provider: "anthropic", + model: "claude", + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "direct", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe( + "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model.", + ); + expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT); + expect(result.payload.text).not.toContain("provider rejected the request payload"); + } + }); + it("uses the throwing fallback candidate model for compaction failure hints", async () => { state.isCompactionFailureErrorMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f3b3dadcc14e..c0fca6f5da1f 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -3063,7 +3063,13 @@ async function runAgentTurnWithFallbackInternal( : isFailoverError(err) ? err.reason === "billing" : isBillingErrorMessage(message); - const isContextOverflow = !isBilling && isLikelyContextOverflowError(message); + // Prefer structured FailoverError reasons over message-text heuristics so + // typed context-overflow/transient failures are not misclassified when the + // error string lacks overflow/HTTP status tokens. + const isContextOverflow = + !isBilling && + ((isFailoverError(err) && err.reason === "context_overflow") || + isLikelyContextOverflowError(message)); const isCompactionFailure = !isBilling && isCompactionFailureError(message); // OAuth/auth-profile failures must reach buildExternalRunFailureReply so // the targeted re-auth/failover copy is surfaced instead of the generic @@ -3078,7 +3084,12 @@ async function runAgentTurnWithFallbackInternal( !shouldSurfaceToControlUi ? classifyProviderRequestError(err) : undefined; - const isTransientHttp = isTransientHttpError(message); + const isTransientHttp = + isTransientHttpError(message) || + (isFailoverError(err) && + (err.reason === "timeout" || + err.reason === "server_error" || + err.reason === "overloaded")); // Drain/restart aborts stay silent and defer to post-restart // main-session recovery, which resumes the interrupted turn (or emits its