diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index be2dff6d7..b11314add 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9143,19 +9143,29 @@ export class AgentSession { // ========================================================================= /** - * Check if an error is retryable (transient errors or usage limits). - * Context overflow is NOT retryable (handled by compaction instead). - * Usage-limit errors are retryable because the retry handler performs credential switching. + * 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`) or a dispose-driven abort (`#isDisposed`) is deliberate + * and MUST settle the turn instead: routing it through retry would orphan + * `#retryPromise` on a continuation that the disposed/aborting guard skips, + * hanging the in-flight `prompt()` in `#waitForPostPromptRecovery`. */ #isRetryableReasonlessAbort(message: AssistantMessage): boolean { return ( message.stopReason === "aborted" && message.content.length === 0 && message.errorMessage === GENERIC_ABORT_SENTINEL && - !this.#abortInProgress + !this.#abortInProgress && + !this.#isDisposed ); } + /** + * Check if an error is retryable (transient errors or usage limits). + * Context overflow is NOT retryable (handled by compaction instead). + * Usage-limit errors are retryable because the retry handler performs credential switching. + */ #isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error" || !message.errorMessage) return false; diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index c1b7d97ba..7e0e099cc 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -690,6 +690,67 @@ describe("AgentSession retry delay cap", () => { expect(last.content).toContainEqual({ type: "text", text: "partial" }); }); + it("does not auto-retry empty reasonless aborts once the session is disposing", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) { + throw new Error("Expected bundled Anthropic test model to exist"); + } + + // A dispose-driven abort produces the same empty/reason-less shape as a + // transient provider abort. It MUST settle the turn instead of entering + // auto-retry: a retry here schedules a continuation that the disposed guard + // skips without resolving #retryPromise, hanging prompt() during shutdown. + const mock = createMockModel({ + responses: [ + { stopReason: "aborted", errorMessage: "Request was aborted" }, + { content: ["should not be reached after dispose"] }, + ], + }); + const agent = new Agent({ + getApiKey: provider => `${provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: mock.stream, + }); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxDelayMs": 5_000, + "retry.maxRetries": 1, + }); + settings.setModelRole("default", `${model.provider}/${model.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + const retryStartEvents: AutoRetryStartEvent[] = []; + session.subscribe(event => { + if (event.type === "auto_retry_start") retryStartEvents.push(event); + }); + + // Enter the disposing window before the empty abort lands. Without the + // #isDisposed guard this prompt would hang on an orphaned retry promise. + session.beginDispose(); + await session.prompt("Trigger empty aborted turn while disposing"); + await session.waitForIdle(); + + expect(retryStartEvents).toHaveLength(0); + // No retry continuation fired, so the second scripted response is untouched. + expect(mock.calls).toHaveLength(1); + const last = lastAssistant(session); + expect(last.stopReason).toBe("aborted"); + }); + it("defaults 502 auto-retry to ten capped backoff attempts", async () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) {