From 21370fc09ba0cb7d218eef3baa718e50abdc389e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 19:23:06 +0000 Subject: [PATCH] fix: allow fallback on timeout aborts Co-authored-by: Larus Ivarsson --- CHANGELOG.md | 1 + src/agents/model-fallback.test.ts | 20 ++++++++++++++++++++ src/agents/model-fallback.ts | 7 +++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ba12fe2..60f23034e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.clawd.bot ### Fixes - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco. +- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. - Doctor: clarify plugin auto-enable hint text in the startup banner. - Gateway: clarify unauthorized handshake responses with token/password mismatch guidance. - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 263dfe58a..639086baa 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -326,6 +326,26 @@ describe("runWithModelFallback", () => { expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); }); + it("falls back when message says aborted but error is a timeout", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("request aborted"), { code: "ETIMEDOUT" })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]?.[0]).toBe("anthropic"); + expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); + }); + it("does not fall back on user aborts", async () => { const cfg = makeCfg(); const run = vi diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 88522344d..ea338ae60 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -33,10 +33,9 @@ function isAbortError(err: unknown): boolean { if (!err || typeof err !== "object") return false; if (isFailoverError(err)) return false; const name = "name" in err ? String(err.name) : ""; - if (name === "AbortError") return true; - const message = - "message" in err && typeof err.message === "string" ? err.message.toLowerCase() : ""; - return message.includes("aborted"); + // Only treat explicit AbortError names as user aborts. + // Message-based checks (e.g., "aborted") can mask timeouts and skip fallback. + return name === "AbortError"; } function shouldRethrowAbort(err: unknown): boolean {