From c2c5bc815e533b98611dbe4d9bcc30f39b1fa314 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Mar 2026 11:46:35 +0700 Subject: [PATCH] Fix Claude stderr diagnostics and hide max effort --- .../server/src/server/agent/agent-manager.ts | 3 + .../claude-agent.max-effort.real.e2e.test.ts | 63 ++++++++++++++++++ .../providers/claude-agent.redesign.test.ts | 66 +++++++++++++++++++ .../agent/providers/claude-agent.test.ts | 1 - .../server/agent/providers/claude-agent.ts | 30 ++++++++- .../claude/sdk-model-resolver.test.ts | 1 - .../providers/claude/sdk-model-resolver.ts | 13 ++-- 7 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c42ee3b35..4c72f5e75 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2022,6 +2022,9 @@ export class AgentManager { lifecycle: agent.lifecycle, activeForegroundTurnId: agent.activeForegroundTurnId, eventTurnId, + error: event.error, + code: event.code, + diagnostic: event.diagnostic, }, "handleStreamEvent: turn_failed", ); diff --git a/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts new file mode 100644 index 000000000..29bcd0eda --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent.max-effort.real.e2e.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; +import pino from "pino"; + +import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; +import { isCommandAvailable } from "../provider-launch-config.js"; +import { ClaudeAgentClient } from "./claude-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; + +const hasClaudeCredentials = + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +async function collectUntilTerminal(session: AgentSession): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Respond with exactly: HELLO_MAX")) { + events.push(event); + if (isTerminalEvent(event)) { + return events; + } + } + return events; +} + +describe("Claude max effort availability (real)", () => { + test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)( + "surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", + async () => { + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "bypassPermissions", + model: "claude-opus-4-6", + thinkingOptionId: "max", + }); + + try { + const events = await collectUntilTerminal(session); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", + ); + + expect(failure).toBeDefined(); + expect(failure?.error).toContain("Claude Code process exited with code 1"); + expect(failure?.code).toBe("1"); + expect(failure?.diagnostic).toContain('Effort level "max" is not available'); + } finally { + await session.close().catch(() => undefined); + } + }, + 30_000, + ); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 392464d30..75d375af4 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -718,6 +718,72 @@ describe("ClaudeAgentSession redesign invariants", () => { } }); + test("captures Claude stderr in the turn failure diagnostic when stderr arrives after process exit", async () => { + const stderrMessage = + 'Error: Effort level "max" is not available for Claude.ai subscribers. Please use "low", "medium", or "high".'; + let capturedOptions: + | { + stderr?: (data: string) => void; + effort?: string; + permissionMode?: string; + } + | undefined; + + sdkQueryFactory.mockImplementation( + ({ options }: { options: { stderr?: (data: string) => void; effort?: string } }) => { + capturedOptions = options; + let failed = false; + return createBaseQueryMock( + vi.fn(async () => { + if (!failed) { + failed = true; + setTimeout(() => { + options.stderr?.(`${stderrMessage}\n`); + }, 0); + throw new Error("Claude Code process exited with code 1"); + } + return { done: true, value: undefined }; + }), + ); + }, + ); + + const loggerSpy = createSpyLogger(); + const client = new ClaudeAgentClient({ + logger: loggerSpy.logger, + queryFactory: sdkQueryFactory, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "bypassPermissions", + thinkingOptionId: "max", + }); + + try { + const events = await collectUntilTerminal(streamSession(session, "trigger max failure")); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", + ); + + expect(capturedOptions?.permissionMode).toBe("bypassPermissions"); + expect(capturedOptions?.effort).toBe("max"); + expect(failure).toMatchObject({ + type: "turn_failed", + error: "Claude Code process exited with code 1", + code: "1", + diagnostic: stderrMessage, + }); + expect(loggerSpy.error).toHaveBeenCalledWith( + expect.objectContaining({ stderr: stderrMessage }), + "Claude Agent SDK stderr", + ); + } finally { + await session.close(); + } + }); + test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { const session = await createSession(); const internal = session as unknown as { diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 4a41418f9..f4363b62b 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -320,7 +320,6 @@ describe("ClaudeAgentClient.listModels", () => { { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, - { id: "max", label: "Max" }, ], }), expect.objectContaining({ diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 48f1534e0..a7f277589 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -206,7 +206,7 @@ function applyRuntimeSettingsToClaudeOptions( // Always use process.execPath — the actual node binary running the daemon. const command = resolved.command === spawnOptions.command ? process.execPath : resolved.command; - return spawn(command, resolved.args, { + const child = spawn(command, resolved.args, { cwd: spawnOptions.cwd, env: { ...applyProviderEnv(spawnOptions.env, runtimeSettings), @@ -215,6 +215,12 @@ function applyRuntimeSettingsToClaudeOptions( signal: spawnOptions.signal, stdio: ["pipe", "pipe", "pipe"], }); + if (typeof options.stderr === "function") { + child.stderr?.on("data", (chunk: Buffer | string) => { + options.stderr?.(chunk.toString()); + }); + } + return child; }, }; } @@ -248,6 +254,8 @@ type ClaudeOptionsLogSummary = { }; const MAX_RECENT_STDERR_CHARS = 4000; +const STDERR_FLUSH_WAIT_MS = 150; +const STDERR_FLUSH_POLL_INTERVAL_MS = 10; function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary { const systemPromptRaw = options.systemPrompt; @@ -1996,6 +2004,25 @@ class ClaudeAgentSession implements AgentSession { return this.recentStderr.trim() || undefined; } + private async awaitRecentStderrAfterProcessExit(error: unknown): Promise { + if (this.getRecentStderrDiagnostic()) { + return; + } + const message = + typeof error === "string" ? error : error instanceof Error ? error.message : ""; + if (!/\bprocess exited with code\b/i.test(message) && !/\bterminated by signal\b/i.test(message)) { + return; + } + + const startedAt = Date.now(); + while (!this.closed && !this.getRecentStderrDiagnostic()) { + if (Date.now() - startedAt >= STDERR_FLUSH_WAIT_MS) { + return; + } + await new Promise((resolve) => setTimeout(resolve, STDERR_FLUSH_POLL_INTERVAL_MS)); + } + } + private createTurnId(owner: "foreground" | "autonomous"): string { return `${owner}-turn-${this.nextTurnOrdinal++}`; } @@ -2213,6 +2240,7 @@ class ClaudeAgentSession implements AgentSession { continue; } if (!this.closed && this.query === activeQuery) { + await this.awaitRecentStderrAfterProcessExit(error); this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed"); } return; diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts index 0564cb11b..9224d1ac2 100644 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts +++ b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.test.ts @@ -72,7 +72,6 @@ describe("resolveClaudeModelsFromSdkModels", () => { { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, - { id: "max", label: "Max" }, ], }), expect.objectContaining({ diff --git a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts index ebea0404b..b2cf28eb9 100644 --- a/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts +++ b/packages/server/src/server/agent/providers/claude/sdk-model-resolver.ts @@ -7,6 +7,9 @@ type ParsedClaudeSdkModelDescriptor = { version: string; }; +// Claude may advertise effort levels that are not usable for all account types. +const DISABLED_CLAUDE_THINKING_EFFORT_LEVELS: readonly string[] = ["max"]; + function normalizeWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -67,15 +70,17 @@ function buildThinkingOptions(model: ModelInfo): { thinkingOptions?: AgentSelectOption[]; defaultThinkingOptionId?: string; } { - const effortLevels = model.supportedEffortLevels ?? []; + const effortLevels = (model.supportedEffortLevels ?? []).filter( + (level) => !DISABLED_CLAUDE_THINKING_EFFORT_LEVELS.includes(level), + ); if (!model.supportsEffort || effortLevels.length === 0) { return {}; } const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({ - id: level, - label: titleCase(level), - })); + id: level, + label: titleCase(level), + })); return { thinkingOptions,