diff --git a/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts new file mode 100644 index 000000000000..812a83fdd86c --- /dev/null +++ b/src/agents/embedded-agent-runner/run.prompt-timeout-fallback.test.ts @@ -0,0 +1,99 @@ +// Coverage for handing replay-safe plugin-harness prompt timeouts to model fallback. +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixture.js"; +import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; +import { + loadRunOverflowCompactionHarness, + MockedFailoverError, + mockedClassifyFailoverReason, + mockedRunEmbeddedAttempt, + overflowBaseRunParams, + resetRunOverflowCompactionHarnessMocks, +} from "./run.overflow-compaction.harness.js"; + +let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent; + +describe("runEmbeddedAgent prompt timeout fallback handoff", () => { + beforeAll(async () => { + ({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness()); + }); + + beforeEach(() => { + resetRunOverflowCompactionHarnessMocks(); + }); + + it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => { + mockedClassifyFailoverReason.mockReturnValue("timeout"); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + promptError: new Error("LLM request timed out."), + promptErrorSource: "prompt", + }), + ); + + const promise = runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-prompt-timeout-fallback", + config: makeModelFallbackCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + }, + }, + }), + }); + + await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); + await expect(promise).rejects.toThrow("LLM request timed out."); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + }); + + it("surfaces replay-invalid prompt timeouts instead of handing them to model fallback", async () => { + mockedClassifyFailoverReason.mockReturnValue("timeout"); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + promptError: new Error("LLM request timed out."), + promptErrorSource: "prompt", + promptTimeoutOutcome: { + message: "Harness abandoned the timed-out turn after provider activity.", + replayInvalid: true, + livenessState: "abandoned", + }, + }), + ); + + let thrown: unknown; + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-prompt-timeout-replay-invalid", + config: makeModelFallbackCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-5.4", + fallbacks: ["anthropic/claude-opus-4-6"], + }, + }, + }, + }), + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(Error); + expect(thrown).not.toBeInstanceOf(MockedFailoverError); + expect(String((thrown as Error | undefined)?.message)).toContain("LLM request timed out."); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 7c7468ce3d17..9e7e95905ff1 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -3116,6 +3116,12 @@ async function runEmbeddedAgentInternal( ); const promptFailoverFailure = promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider }); + const promptTimeoutFallbackSafe = + promptErrorSource === "prompt" && + promptFailoverReason === "timeout" && + !attempt.codexAppServerFailure && + attempt.promptTimeoutOutcome?.replayInvalid !== true && + attempt.replayMetadata.replaySafe; // Capture the failing profile before auth-profile rotation mutates `lastProfileId`. const failedPromptProfileId = lastProfileId; const logPromptFailoverDecision = createFailoverDecisionLogger({ @@ -3147,6 +3153,7 @@ async function runEmbeddedAgentInternal( failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, harnessOwnsTransport: pluginHarnessOwnsTransport, + promptTimeoutFallbackSafe, profileRotated: false, }); if ( @@ -3186,6 +3193,7 @@ async function runEmbeddedAgentInternal( failoverFailure: promptFailoverFailure, failoverReason: promptFailoverReason, harnessOwnsTransport: pluginHarnessOwnsTransport, + promptTimeoutFallbackSafe, profileRotated: true, }); } diff --git a/src/agents/embedded-agent-runner/run/failover-policy.test.ts b/src/agents/embedded-agent-runner/run/failover-policy.test.ts index fe2f8caac089..7f773f7701b0 100644 --- a/src/agents/embedded-agent-runner/run/failover-policy.test.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.test.ts @@ -581,6 +581,44 @@ describe("resolveRunFailoverDecision", () => { }); }); + it("falls back on fallback-safe harness-owned prompt timeouts", () => { + expect( + resolveRunFailoverDecision({ + stage: "prompt", + aborted: false, + externalAbort: false, + fallbackConfigured: true, + failoverFailure: true, + failoverReason: "timeout", + harnessOwnsTransport: true, + promptTimeoutFallbackSafe: true, + profileRotated: true, + }), + ).toEqual({ + action: "fallback_model", + reason: "timeout", + }); + }); + + it("surfaces fallback-safe harness-owned prompt timeouts when no fallback is configured", () => { + expect( + resolveRunFailoverDecision({ + stage: "prompt", + aborted: false, + externalAbort: false, + fallbackConfigured: false, + failoverFailure: true, + failoverReason: "timeout", + harnessOwnsTransport: true, + promptTimeoutFallbackSafe: true, + profileRotated: true, + }), + ).toEqual({ + action: "surface_error", + reason: "timeout", + }); + }); + it("surfaces error on LLM idle timeout when no fallback is configured and rotation is exhausted", () => { expect( resolveRunFailoverDecision({ diff --git a/src/agents/embedded-agent-runner/run/failover-policy.ts b/src/agents/embedded-agent-runner/run/failover-policy.ts index c1a1a23c2df2..f91ee89f81f7 100644 --- a/src/agents/embedded-agent-runner/run/failover-policy.ts +++ b/src/agents/embedded-agent-runner/run/failover-policy.ts @@ -50,6 +50,7 @@ type PromptDecisionParams = { failoverFailure: boolean; failoverReason: FailoverReason | null; harnessOwnsTransport?: boolean; + promptTimeoutFallbackSafe?: boolean; profileRotated: boolean; }; @@ -179,6 +180,14 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R }; } if (params.harnessOwnsTransport && params.failoverReason === "timeout") { + // Plugin harness lifecycle timeouts must stay inside the harness boundary; + // only prompt request timeouts proven replay-safe may enter model fallback. + if (params.promptTimeoutFallbackSafe === true && params.fallbackConfigured) { + return { + action: "fallback_model", + reason: "timeout", + }; + } return { action: "surface_error", reason: params.failoverReason,