mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user