Fix Claude stderr diagnostics and hide max effort
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user