Fix Claude stderr diagnostics and hide max effort
This commit is contained in:
@@ -2022,6 +2022,9 @@ export class AgentManager {
|
|||||||
lifecycle: agent.lifecycle,
|
lifecycle: agent.lifecycle,
|
||||||
activeForegroundTurnId: agent.activeForegroundTurnId,
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
||||||
eventTurnId,
|
eventTurnId,
|
||||||
|
error: event.error,
|
||||||
|
code: event.code,
|
||||||
|
diagnostic: event.diagnostic,
|
||||||
},
|
},
|
||||||
"handleStreamEvent: turn_failed",
|
"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 () => {
|
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||||
const session = await createSession();
|
const session = await createSession();
|
||||||
const internal = session as unknown as {
|
const internal = session as unknown as {
|
||||||
|
|||||||
@@ -320,7 +320,6 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
{ id: "medium", label: "Medium" },
|
{ id: "medium", label: "Medium" },
|
||||||
{ id: "high", label: "High" },
|
{ id: "high", label: "High" },
|
||||||
{ id: "max", label: "Max" },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
// Always use process.execPath — the actual node binary running the daemon.
|
// Always use process.execPath — the actual node binary running the daemon.
|
||||||
const command =
|
const command =
|
||||||
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
|
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
|
||||||
return spawn(command, resolved.args, {
|
const child = spawn(command, resolved.args, {
|
||||||
cwd: spawnOptions.cwd,
|
cwd: spawnOptions.cwd,
|
||||||
env: {
|
env: {
|
||||||
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||||
@@ -215,6 +215,12 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
signal: spawnOptions.signal,
|
signal: spawnOptions.signal,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
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 MAX_RECENT_STDERR_CHARS = 4000;
|
||||||
|
const STDERR_FLUSH_WAIT_MS = 150;
|
||||||
|
const STDERR_FLUSH_POLL_INTERVAL_MS = 10;
|
||||||
|
|
||||||
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
|
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
|
||||||
const systemPromptRaw = options.systemPrompt;
|
const systemPromptRaw = options.systemPrompt;
|
||||||
@@ -1996,6 +2004,25 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return this.recentStderr.trim() || undefined;
|
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 {
|
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||||
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
||||||
}
|
}
|
||||||
@@ -2213,6 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!this.closed && this.query === activeQuery) {
|
if (!this.closed && this.query === activeQuery) {
|
||||||
|
await this.awaitRecentStderrAfterProcessExit(error);
|
||||||
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ describe("resolveClaudeModelsFromSdkModels", () => {
|
|||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
{ id: "medium", label: "Medium" },
|
{ id: "medium", label: "Medium" },
|
||||||
{ id: "high", label: "High" },
|
{ id: "high", label: "High" },
|
||||||
{ id: "max", label: "Max" },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ type ParsedClaudeSdkModelDescriptor = {
|
|||||||
version: string;
|
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 {
|
function normalizeWhitespace(value: string): string {
|
||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -67,15 +70,17 @@ function buildThinkingOptions(model: ModelInfo): {
|
|||||||
thinkingOptions?: AgentSelectOption[];
|
thinkingOptions?: AgentSelectOption[];
|
||||||
defaultThinkingOptionId?: string;
|
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) {
|
if (!model.supportsEffort || effortLevels.length === 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
||||||
id: level,
|
id: level,
|
||||||
label: titleCase(level),
|
label: titleCase(level),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user