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:
@@ -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 () => {}),
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user