From 38b61ae342526e234598aedcc69251e96bf49335 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 7 Aug 2026 22:31:52 +0200 Subject: [PATCH] fix(session): honored explicit retry-after over reason backoff - A provider-supplied retry-after now bypasses the transient rate/concurrency heuristic window instead of being overridden by it (regression from the subscription-cap retry change). - Updated event-controller/ui-helpers test doubles for provenance-gated renderer selection (hasBuiltInTool), aggregated retryErrors on auto_retry_end, and Bedrock override compat gaining streamIdleTimeoutMs. --- .../coding-agent/src/session/turn-recovery.ts | 5 +++- .../test/agent-session-retry-fallback.test.ts | 24 ++++++++++++------- .../test/event-controller-cursor-todo.test.ts | 2 +- ...-controller-mixed-assistant-render.test.ts | 1 + .../test/issue-4348-repro.test.ts | 1 + .../test/job-poll-displacement.test.ts | 6 +++-- .../mid-turn-rebuild-pending-tool.test.ts | 1 + .../model-registry-default-config.test.ts | 3 +++ .../event-controller-args-reveal.test.ts | 4 ++-- .../event-controller-read-grouping.test.ts | 2 +- ...vent-controller-task-async-updates.test.ts | 4 ++-- ...event-controller-toolcall-finalize.test.ts | 4 ++-- .../utils/render-initial-messages.test.ts | 2 ++ ...ssue-6879-tool-double-render-retry.test.ts | 3 +++ 14 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 69078e959..d52141a75 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -1611,10 +1611,13 @@ export class TurnRecovery { // Transient rate/concurrency caps stay on the same credential, but must // honor their reason-specific windows. The default exponential base // (≈500ms, capped at 8s) otherwise re-hits the cap and burns the retry - // budget before either window can clear. + // budget before either window can clear. An explicit provider + // retry-after is authoritative in both directions, so the heuristic + // window only applies when the error carries no parsed timing. if ( !staleOpenAIResponsesReplayError && !AIError.is(id, AIError.Flag.UsageLimit) && + parsedRetryAfterMs === undefined && (rateLimitReason === "CONCURRENT_LIMIT" || rateLimitReason === "RATE_LIMIT_EXCEEDED") ) { const reasonBackoffMs = calculateRateLimitBackoffMs(rateLimitReason); 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 1dcfa977d..fee96de00 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -2393,14 +2393,22 @@ describe("AgentSession retry fallback", () => { role: "default", }, ]); - expect(retryEndEvents).toEqual([ - { - type: "auto_retry_end", - success: false, - attempt: 1, - finalError: refusalMessage, - }, - ]); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]).toMatchObject({ + type: "auto_retry_end", + success: false, + attempt: 1, + finalError: refusalMessage, + }); + // The superseded first attempt is aggregated onto the terminal event so + // the transcript renders one budget-labeled error, not per-attempt rows. + expect(retryEndEvents[0]?.retryErrors).toHaveLength(1); + expect(retryEndEvents[0]?.retryErrors?.[0]?.retryRecovery).toMatchObject({ + kind: "auto-retry", + recovery: "model", + status: "superseded", + attempt: 1, + }); }); it("emits auto_retry_end when a mid-saga classifier refusal has no fallback to switch to", async () => { diff --git a/packages/coding-agent/test/event-controller-cursor-todo.test.ts b/packages/coding-agent/test/event-controller-cursor-todo.test.ts index cb55c478a..ef67d2782 100644 --- a/packages/coding-agent/test/event-controller-cursor-todo.test.ts +++ b/packages/coding-agent/test/event-controller-cursor-todo.test.ts @@ -45,7 +45,7 @@ function createFixture(): Fixture { // (`#handleMessageUpdate`) only runs while one exists. streamingComponent: { setHideThinkingBlock: vi.fn(), markTranscriptBlockFinalized: vi.fn() }, streamingMessage: undefined, - viewSession: { isStreaming: false, getToolByName: () => undefined }, + viewSession: { isStreaming: false, getToolByName: () => undefined, hasBuiltInTool: () => true }, sessionManager: { getCwd: () => "/tmp" }, chatContainer: { addChild: (block: unknown) => blocks.push(block), diff --git a/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts b/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts index 4f3e495f9..94515123e 100644 --- a/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts +++ b/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts @@ -61,6 +61,7 @@ function createFixture(hideToolActivity = false) { } as unknown as TUI; const viewSession = { getToolByName: () => undefined, + hasBuiltInTool: () => true, extensionRunner: undefined, isTtsrAbortPending: false, retryAttempt: 0, diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index fb06c380c..5f6e9061f 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -74,6 +74,7 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex viewSession: { buildTranscriptSessionContext: () => transcript, getToolByName: () => undefined, + hasBuiltInTool: () => true, extensionRunner: undefined, sessionManager: { getEntries: vi.fn(() => []), diff --git a/packages/coding-agent/test/job-poll-displacement.test.ts b/packages/coding-agent/test/job-poll-displacement.test.ts index dcd1eedb9..7c16183c2 100644 --- a/packages/coding-agent/test/job-poll-displacement.test.ts +++ b/packages/coding-agent/test/job-poll-displacement.test.ts @@ -175,9 +175,9 @@ describe("EventController displaces consecutive waiting polls", () => { toolOutputExpanded: false, pendingTools, chatContainer, - session: { getToolByName: () => undefined }, + session: { getToolByName: () => undefined, hasBuiltInTool: () => true }, showWarning: vi.fn(), - viewSession: { getToolByName: () => undefined }, + viewSession: { getToolByName: () => undefined, hasBuiltInTool: () => true }, sessionManager: { getCwd: () => process.cwd() }, setTodos: vi.fn(), } as unknown as InteractiveModeContext; @@ -423,6 +423,7 @@ describe("UiHelpers.renderSessionContext collapses repeated todo snapshots", () session: { retryAttempt: 0, getToolByName: () => undefined, + hasBuiltInTool: () => true, sessionManager: { getCwd: () => process.cwd() }, }, get viewSession() { @@ -500,6 +501,7 @@ describe("UiHelpers.renderSessionContext collapses repeated todo snapshots", () session: { retryAttempt: 0, getToolByName: () => undefined, + hasBuiltInTool: () => true, sessionManager: { getCwd: () => process.cwd() }, isStreaming: true, }, diff --git a/packages/coding-agent/test/mid-turn-rebuild-pending-tool.test.ts b/packages/coding-agent/test/mid-turn-rebuild-pending-tool.test.ts index 4416b0f39..fe01e2807 100644 --- a/packages/coding-agent/test/mid-turn-rebuild-pending-tool.test.ts +++ b/packages/coding-agent/test/mid-turn-rebuild-pending-tool.test.ts @@ -49,6 +49,7 @@ function createFixture(opts: { isStreaming: boolean }) { const session = { retryAttempt: 0, getToolByName: () => undefined, + hasBuiltInTool: () => true, sessionManager: { getCwd: () => process.cwd() }, isStreaming: opts.isStreaming, }; diff --git a/packages/coding-agent/test/model-registry-default-config.test.ts b/packages/coding-agent/test/model-registry-default-config.test.ts index 45b461ede..8564808cc 100644 --- a/packages/coding-agent/test/model-registry-default-config.test.ts +++ b/packages/coding-agent/test/model-registry-default-config.test.ts @@ -46,6 +46,9 @@ describe("ModelRegistry default custom models config", () => { supportsLongPromptCacheRetention: false, promptCacheMinimumTokens: 1024, promptCacheMaximumCheckpoints: 4, + // Reasoning-tier Bedrock stream-stall watchdog widening applies to + // overrides too (model compat generation). + streamIdleTimeoutMs: 900000, }); }); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts index 142250ad7..d9f3bdd99 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts @@ -56,8 +56,8 @@ function createFixture(streamingMessage: AssistantMessage) { noteDisplayableThinkingContent: vi.fn(() => false), chatContainer: { addChild: vi.fn() }, toolOutputExpanded: false, - session: { getToolByName: () => undefined }, - viewSession: { getToolByName: () => undefined }, + session: { getToolByName: () => undefined, hasBuiltInTool: () => true }, + viewSession: { getToolByName: () => undefined, hasBuiltInTool: () => true }, sessionManager: { getCwd: () => process.cwd() }, } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts index dec840515..26f6ba000 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts @@ -76,7 +76,7 @@ function assistantMessage(content: Block[]): AssistantMessage { function createFixture() { const chatContainer = new Container(); - const sessionMock = { getToolByName: () => undefined, extensionRunner: undefined }; + const sessionMock = { getToolByName: () => undefined, hasBuiltInTool: () => true, extensionRunner: undefined }; const ctx = { isInitialized: true, init: vi.fn(async () => {}), diff --git a/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts index f94db4fc7..7ff96c096 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-task-async-updates.test.ts @@ -68,9 +68,9 @@ describe("EventController async update finalization", () => { transcriptMessageComponents: new WeakMap(), pendingTools, chatContainer, - session: { getToolByName: () => undefined, isStreaming: true }, + session: { getToolByName: () => undefined, hasBuiltInTool: () => true, isStreaming: true }, showWarning: vi.fn(), - viewSession: { getToolByName: () => undefined }, + viewSession: { getToolByName: () => undefined, hasBuiltInTool: () => true }, sessionManager: { getCwd: () => process.cwd() }, setTodos: vi.fn(), } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts index ecc591898..14fe2138c 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts @@ -64,8 +64,8 @@ function createFixture(streamingMessage: AssistantMessage) { chatContainer: { addChild: vi.fn((child: { seal?(): void }) => mountedComponents.push(child)) }, toolOutputExpanded: false, settings, - session: { getToolByName: () => undefined }, - viewSession: { getToolByName: () => undefined }, + session: { getToolByName: () => undefined, hasBuiltInTool: () => true }, + viewSession: { getToolByName: () => undefined, hasBuiltInTool: () => true }, clearTransientSessionUi: () => {}, sessionManager: { getCwd: () => process.cwd() }, } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index a7a3d569c..f96a7ebe2 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -75,6 +75,7 @@ function makeCtx(): { session: { buildTranscriptSessionContext: transcriptSpy }, viewSession: { buildTranscriptSessionContext: transcriptSpy, + hasBuiltInTool: () => true, sessionManager: { buildSessionContext: llmContextSpy, getEntries: vi.fn(() => []), @@ -173,6 +174,7 @@ function makeRenderCtx( viewSession: { buildTranscriptSessionContext: () => transcript, getToolByName: () => undefined, + hasBuiltInTool: () => true, extensionRunner: undefined, sessionManager: { getEntries: vi.fn(() => []), diff --git a/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts b/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts index 4cbfb0cae..72b0d8ba0 100644 --- a/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts +++ b/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts @@ -97,6 +97,9 @@ describe("issue #6879 — tool output appears twice after a superseded turn", () settings: Settings.isolated(), modelRegistry, }); + // The session is constructed with no tools; bash is a built-in in real + // sessions, so provenance-gated rendering must treat it as one here. + vi.spyOn(session, "hasBuiltInTool").mockReturnValue(true); mode = new InteractiveMode(session, "test"); mode.ui.requestRender = vi.fn(); Object.defineProperty(session, "isStreaming", { configurable: true, get: () => true });