From bc179c223440ad22dd32f75b1b0751306b5be088 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Mon, 15 Jun 2026 15:11:04 +0200 Subject: [PATCH] fix(cli): refine Claude Code session limit retry handling --- .../src/context/llm/claude-code-runtime.ts | 15 ++-- .../context/llm/claude-code-runtime.test.ts | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts index 185fd5b6..971bd8b4 100644 --- a/packages/cli/src/context/llm/claude-code-runtime.ts +++ b/packages/cli/src/context/llm/claude-code-runtime.ts @@ -151,15 +151,16 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set(); } -// "session limit" is the Claude Code subscription cap ("You've hit your session -// limit · resets …"); the rest are transient 429-style throttling. All mean -// Claude Code authenticated successfully, so they must not be read as auth -// failures by the governor classifier or the auth probe. +// Transient 429-style throttling that a retry can clear. Drives both the runtime +// retry classifier and the auth probe message; all mean Claude Code authenticated, +// so they must never surface as auth failures. The subscription cap is excluded — +// see CLAUDE_SESSION_LIMIT_MARKERS — because retrying it only re-hits the cap. const CLAUDE_RATE_LIMIT_ERROR_MARKERS = - /\b429\b|rate limit|session limit|usage limit|too many requests|quota exceeded|overloaded|max_retries/i; + /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i; -// The subscription cap is its own case: re-authenticating and retrying both fail -// until reset, so it gets a distinct message from transient rate limiting. +// The subscription cap ("You've hit your session limit · resets …") is the +// non-retryable case: re-authenticating and retrying both fail until reset, so it +// stays out of the retry classifier and gets its own probe message. const CLAUDE_SESSION_LIMIT_MARKERS = /session limit|usage limit/i; function describeClaudeProbeFailure(message: string): string { diff --git a/packages/cli/test/context/llm/claude-code-runtime.test.ts b/packages/cli/test/context/llm/claude-code-runtime.test.ts index c070f44c..50d4e5aa 100644 --- a/packages/cli/test/context/llm/claude-code-runtime.test.ts +++ b/packages/cli/test/context/llm/claude-code-runtime.test.ts @@ -219,6 +219,75 @@ describe('ClaudeCodeKtxLlmRuntime', () => { }); }); + it('surfaces a Claude Code session limit without retrying the capped query', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + result: '', + errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"], + } as never), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady: vi.fn().mockResolvedValue(undefined), report: vi.fn(), maxRetryAttempts: () => 6 } as never, + }); + + // The subscription cap cannot clear until reset, so retrying only re-hits the + // cap; surface it on the first attempt instead of spinning maxRetryAttempts times. + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).rejects.toThrow(/session limit/); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('cooperatively retries a session-limit result when the SDK reports a structured rejected reset', async () => { + const waitForReady = vi.fn().mockResolvedValue(undefined); + const report = vi.fn(); + const resetAtMs = 1_700_000_000_000; + const query = vi + .fn() + .mockReturnValueOnce( + stream([ + { + type: 'rate_limit_event', + rate_limit_info: { status: 'rejected', resetsAt: resetAtMs, rateLimitType: 'five_hour', utilization: 1 }, + } as unknown as SDKMessage, + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + result: '', + errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"], + } as never), + ]), + ) + .mockReturnValueOnce(stream([resultMessage({ result: 'ok' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + rateLimitGovernor: { waitForReady, report, maxRetryAttempts: () => 6 } as never, + }); + + // A structured rejected event carries the real reset time, so the governor pauses + // until reset and resumes — the session-limit text must not suppress that cooperative + // retry, unlike the bare-string case above where no reset time is available to wait on. + await expect(runtime.generateText({ role: 'default', prompt: 'hello' })).resolves.toBe('ok'); + expect(query).toHaveBeenCalledTimes(2); + expect(report).toHaveBeenCalledWith({ + provider: 'claude-subscription', + status: 'rejected', + resetAtMs, + rateLimitType: 'five_hour', + utilization: 1, + }); + }); + it('reports Claude Code api retry messages as warning signals', async () => { const report = vi.fn(); const query = vi.fn((_input: any) =>