Fix Claude stderr diagnostics and hide max effort

This commit is contained in:
Mohamed Boudra
2026-03-25 11:46:35 +07:00
parent bd635cf07e
commit c2c5bc815e
7 changed files with 170 additions and 7 deletions
@@ -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",
);
@@ -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<AgentStreamEvent[]> {
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<AgentStreamEvent, { type: "turn_failed" }> =>
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,
);
});
@@ -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<AgentStreamEvent, { type: "turn_failed" }> =>
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 {
@@ -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({
@@ -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<void> {
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;
@@ -72,7 +72,6 @@ describe("resolveClaudeModelsFromSdkModels", () => {
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
],
}),
expect.objectContaining({
@@ -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,