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).
This commit is contained in:
Ayaan Zaidi
2026-07-12 07:25:04 +05:30
parent d03defb8b4
commit 57e0ea8f75
2 changed files with 90 additions and 2 deletions
@@ -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) => {
+13 -2
View File
@@ -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