diff --git a/packages/cli/src/context/llm/claude-code-runtime.ts b/packages/cli/src/context/llm/claude-code-runtime.ts index 633b024f..185fd5b6 100644 --- a/packages/cli/src/context/llm/claude-code-runtime.ts +++ b/packages/cli/src/context/llm/claude-code-runtime.ts @@ -151,7 +151,26 @@ function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set(); } -const CLAUDE_RATE_LIMIT_ERROR_MARKERS = /\b429\b|rate limit|too many requests|quota exceeded|overloaded|max_retries/i; +// "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. +const CLAUDE_RATE_LIMIT_ERROR_MARKERS = + /\b429\b|rate limit|session limit|usage 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. +const CLAUDE_SESSION_LIMIT_MARKERS = /session limit|usage limit/i; + +function describeClaudeProbeFailure(message: string): string { + if (CLAUDE_SESSION_LIMIT_MARKERS.test(message)) { + return `Claude Code session limit reached. Wait for the reset shown, then rerun setup or the command. Details: ${message}`; + } + if (CLAUDE_RATE_LIMIT_ERROR_MARKERS.test(message)) { + return `Claude Code is rate limited. Retry shortly, then rerun setup or the command. Details: ${message}`; + } + return `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`; +} function normalizeClaudeResetAtMs(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { @@ -497,7 +516,7 @@ export async function runClaudeCodeAuthProbe(input: { const message = error instanceof Error ? error.message : String(error); return { ok: false, - message: `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`, + message: describeClaudeProbeFailure(message), }; } } 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 4e7a8e48..c070f44c 100644 --- a/packages/cli/test/context/llm/claude-code-runtime.test.ts +++ b/packages/cli/test/context/llm/claude-code-runtime.test.ts @@ -738,4 +738,70 @@ describe('ClaudeCodeKtxLlmRuntime', () => { message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.', }); }); + + it('reports a Claude Code session limit as a session limit, not an auth or ktx failure', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + errors: ["You've hit your session limit · resets 10:50pm (Asia/Saigon)"], + }), + ]), + ); + + const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} }); + const message = (result as { message: string }).message; + + expect(result.ok).toBe(false); + // Auth worked; the subscription is capped. Must not tell the user to re-authenticate. + expect(message).not.toContain('authentication is not usable'); + // Frame it as Claude Code's session limit, tell them to wait, and preserve the reset text. + expect(message).toContain('Claude Code session limit reached'); + expect(message).toContain('Wait for the reset shown'); + expect(message).toContain('resets 10:50pm (Asia/Saigon)'); + }); + + it('reports a Claude Code rate limit as a rate limit, not an auth failure', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + errors: ['API Error: 429 Too Many Requests'], + }), + ]), + ); + + const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} }); + const message = (result as { message: string }).message; + + expect(result.ok).toBe(false); + expect(message).not.toContain('authentication is not usable'); + expect(message).toContain('Claude Code is rate limited'); + expect(message).toContain('429 Too Many Requests'); + }); + + it('still reports a genuine auth failure as an auth failure', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + resultMessage({ + subtype: 'error_during_execution', + is_error: true, + errors: ['Invalid API key · Please run `claude login`'], + }), + ]), + ); + + const result = await runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: {} }); + const message = (result as { message: string }).message; + + expect(result.ok).toBe(false); + expect(message).toContain('authentication is not usable'); + expect(message).not.toContain('session limit reached'); + expect(message).not.toContain('rate limited'); + }); });