diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index da3550531..120fb546b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a bare `Request was aborted` provider abort that arrives as `stopReason: "error"` (a stalled or dropped stream reported as an error rather than an abort) never being auto-retried despite `retry.enabled`. The reason-less-abort retry gate now recognizes the empty generic-abort sentinel under either `stopReason: "aborted"` or `"error"`, while deliberate user interrupts, dispose-driven aborts, and streaming-edit guard aborts still settle without retry ([#5375](https://github.com/can1357/oh-my-pi/issues/5375)). + ## [16.5.0] - 2026-07-13 ### Breaking Changes diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index dcc716744..67c6daffa 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13829,19 +13829,23 @@ export class AgentSession { } /** - * Retry an empty, reason-less provider abort: a turn that ended `aborted` - * with no content and the generic sentinel (bare `abort()`), but only while - * the session is neither aborting nor tearing down. A user/lifecycle abort - * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a - * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` — - * auto-generated-file guard or failed-patch preview) is deliberate and MUST - * settle the turn instead: routing it through retry would orphan - * `#retryPromise` on a continuation the guard skips (hanging the in-flight - * `prompt()`) or silently undo the guard's intended abort. + * Retry an empty, reason-less provider abort: a turn with no content that + * carries the generic sentinel (bare `abort()`), whether the provider + * finalized it as `stopReason: "aborted"` or leaked it as `stopReason: + * "error"` (a stalled/dropped stream reported as an error rather than an + * abort — issue #5375). Only fires while the session is neither aborting nor + * tearing down. A user/lifecycle abort (`#abortInProgress`), a dispose-driven + * abort (`#isDisposed`), or a session-induced streaming-edit guard abort + * (`#streamingEditAbortTriggered` — auto-generated-file guard or failed-patch + * preview) is deliberate and MUST settle the turn instead: routing it through + * retry would orphan `#retryPromise` on a continuation the guard skips + * (hanging the in-flight `prompt()`) or silently undo the guard's intended + * abort. Deliberate user interrupts (`UserInterrupt`) and silent aborts carry + * their own marker, not the generic sentinel, so they never match here. */ #isRetryableReasonlessAbort(message: AssistantMessage): boolean { if ( - message.stopReason !== "aborted" || + (message.stopReason !== "aborted" && message.stopReason !== "error") || message.content.length !== 0 || this.#abortInProgress || this.#isDisposed || @@ -13851,7 +13855,7 @@ export class AgentSession { } const id = this.#classifyRetryMessage(message); - if (AIError.is(id, AIError.Flag.Abort)) return true; + if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true; if (!this.#isGenericAbortSentinel(message)) return false; message.errorId = AIError.create(AIError.Flag.Abort); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index d9713b1fa..7de2fd041 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1602,14 +1602,18 @@ describe("AgentSession retry fallback", () => { expect(lastAssistant.errorMessage).toBe(envelopeError); }); - it("does not auto-retry generic Request was aborted. errors", async () => { + it("auto-retries a bare Request was aborted error-stop turn (issue #5375)", async () => { const model = getBundledModel("openai", "gpt-4o-mini"); if (!model) { throw new Error("Expected bundled OpenAI test model to exist"); } const requestedModels: string[] = []; - const mock = createMockModel({ handler: () => ({ throw: "Request was aborted." }) }); + // A stalled/dropped stream that the provider surfaces as stopReason:"error" + // carrying the bare abort sentinel, then a clean recovery on the retry. + const mock = createMockModel({ + responses: [{ throw: "Request was aborted." }, { content: ["recovered after bare abort error"] }], + }); const agent = new Agent({ getApiKey: model => `${model.provider}-test-key`, initialState: { @@ -1637,17 +1641,23 @@ describe("AgentSession retry fallback", () => { settings, modelRegistry, }); + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); - await session.prompt("Do not retry generic abort text"); + await session.prompt("Retry the bare abort error"); await session.waitForIdle(); - expect(requestedModels).toEqual([`${model.provider}/${model.id}`]); - expect(retryStartEvents).toHaveLength(0); - expect(retryEndEvents).toHaveLength(0); + // Same model, retried once (no model fallback for a reason-less abort). + expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]); + expect(retryStartEvents).toHaveLength(1); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 }); const lastAssistant = getLastAssistantMessage(session); - expect(lastAssistant.stopReason).toBe("error"); - expect(lastAssistant.errorMessage).toBe("Request was aborted."); + expect(lastAssistant.stopReason).toBe("stop"); + expect(lastAssistant.content).toContainEqual({ + type: "text", + text: "recovered after bare abort error", + }); }); it("matches plain fallback roles for compat-routed primary models", async () => {