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.
This commit is contained in:
can1357
2026-08-07 22:31:52 +02:00
parent 5b3bed18b5
commit 38b61ae342
14 changed files with 43 additions and 19 deletions
@@ -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);
@@ -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 () => {
@@ -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),
@@ -61,6 +61,7 @@ function createFixture(hideToolActivity = false) {
} as unknown as TUI;
const viewSession = {
getToolByName: () => undefined,
hasBuiltInTool: () => true,
extensionRunner: undefined,
isTtsrAbortPending: false,
retryAttempt: 0,
@@ -74,6 +74,7 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex
viewSession: {
buildTranscriptSessionContext: () => transcript,
getToolByName: () => undefined,
hasBuiltInTool: () => true,
extensionRunner: undefined,
sessionManager: {
getEntries: vi.fn(() => []),
@@ -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,
},
@@ -49,6 +49,7 @@ function createFixture(opts: { isStreaming: boolean }) {
const session = {
retryAttempt: 0,
getToolByName: () => undefined,
hasBuiltInTool: () => true,
sessionManager: { getCwd: () => process.cwd() },
isStreaming: opts.isStreaming,
};
@@ -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,
});
});
@@ -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;
@@ -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 () => {}),
@@ -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;
@@ -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;
@@ -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(() => []),
@@ -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 });