From ea3882f8a328648264bb6a982ecf753fd17d366c Mon Sep 17 00:00:00 2001 From: Mathews-Tom Date: Wed, 15 Jul 2026 22:53:47 +0530 Subject: [PATCH] fix(coding-agent): close terminal retry lifecycle --- .../coding-agent/src/session/agent-session.ts | 18 +++++-- .../test/agent-session-prewalk.test.ts | 6 ++- .../test/agent-session-retry-fallback.test.ts | 48 +++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c5e4bb165..b2328b00b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2232,8 +2232,8 @@ export class AgentSession { const prewalk = this.#prewalk; if (!prewalk || context?.message.role !== "assistant") return; - const todoCalledThisTurn = context.toolResults.some(result => result.toolName === "todo"); - if (todoCalledThisTurn) { + const todoSucceededThisTurn = context.toolResults.some(result => result.toolName === "todo" && !result.isError); + if (todoSucceededThisTurn) { this.#prewalkTodoSeen = true; } @@ -2268,9 +2268,6 @@ export class AgentSession { // not establish the list. The gate keys on the ACTIVE tool set, not the // registry: a registered-but-deactivated todo (e.g. a restricted // active-tool slate) is uncallable and would deadlock the switch. - if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) { - this.#prewalkTodoSeen = true; - } const todoGateOpen = this.#prewalkTodoSeen || !this.getActiveToolNames().includes("todo"); const action = todoGateOpen ? context.toolResults.find(result => PREWALK_ACTION_TOOLS[result.toolName]) @@ -4584,6 +4581,17 @@ export class AgentSession { if (this.#isClassifierRefusal(msg)) { this.#removeAssistantMessageFromActiveContext(msg); } + if (msg.stopReason === "error" && this.#retryAttempt > 0) { + const attempt = this.#retryAttempt; + this.#retryAttempt = 0; + await this.#emitSessionEvent({ + type: "auto_retry_end", + success: false, + attempt, + finalError: msg.errorMessage, + }); + this.#clearPendingRecoveredRetryErrors(); + } this.#resolveRetry(); if (!checkedCompaction) { diff --git a/packages/coding-agent/test/agent-session-prewalk.test.ts b/packages/coding-agent/test/agent-session-prewalk.test.ts index 601215f7a..89baddcbf 100644 --- a/packages/coding-agent/test/agent-session-prewalk.test.ts +++ b/packages/coding-agent/test/agent-session-prewalk.test.ts @@ -241,7 +241,11 @@ describe("AgentSession prewalk", () => { const failingTodoTool: AgentTool = { ...todoTool, async execute() { - throw new Error("todo update failed"); + return { + content: [{ type: "text", text: "todo update failed" }], + details: undefined, + isError: true, + }; }, }; const mock = createMockModel({ 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 226d42d80..21fe0eebf 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -1683,6 +1683,54 @@ describe("AgentSession retry fallback", () => { expect(lastAssistant.errorMessage).toBe(envelopeError); }); + it("closes the retry lifecycle when a retried turn ends with a non-retryable error", async () => { + const model = getBundledModel("openai", "gpt-4o-mini"); + if (!model) throw new Error("Expected bundled OpenAI test model to exist"); + + const retryableError = "rate limit exceeded retry-after-ms=5"; + const terminalError = "invalid request: schema violation"; + const requestedModels: string[] = []; + const mock = createMockModel(); + const agent = new Agent({ + getApiKey: requestedModel => `${requestedModel.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: (requestedModel, context, options) => { + requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`); + mock.push({ throw: requestedModels.length === 1 ? retryableError : terminalError }); + return mock.stream(requestedModel, context, options); + }, + }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.baseDelayMs": 5, + "retry.maxRetries": 1, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + const { retryStartEvents, retryEndEvents } = trackRetryEvents(session); + vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); + + await session.prompt("Retry once, then surface a terminal validation failure"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]); + expect(retryStartEvents).toHaveLength(1); + expect(retryEndEvents).toEqual([ + expect.objectContaining({ success: false, attempt: 1, finalError: terminalError }), + ]); + expect(session.retryAttempt).toBe(0); + expect(getLastAssistantMessage(session).stopReason).toBe("error"); + }); + it("auto-retries a bare Request was aborted error-stop turn (issue #5375)", async () => { const model = getBundledModel("openai", "gpt-4o-mini"); if (!model) {