Merge pull request 'fix(agent-claude-code): handle missing result line gracefully' (#576) from fix/574-silent-fail-handling into main
This commit is contained in:
@@ -301,6 +301,179 @@ describe("storeClaudeCodeDetail", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseClaudeCodeStreamOutput — incomplete output (no result line)", () => {
|
||||||
|
test("Test 1.1: parses stream with turns but no result line", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "sess-incomplete-1",
|
||||||
|
model: "claude-sonnet-4.5",
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Starting work..." }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "This is the last assistant message." }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const stdout = lines.join("\n");
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(stdout);
|
||||||
|
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.subtype).toBe("incomplete");
|
||||||
|
expect(parsed!.result).toBe("This is the last assistant message.");
|
||||||
|
expect(parsed!.sessionId).toBe("sess-incomplete-1");
|
||||||
|
expect(parsed!.model).toBe("claude-sonnet-4.5");
|
||||||
|
expect(parsed!.turns).toHaveLength(2);
|
||||||
|
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
|
||||||
|
expect(parsed!.numTurns).toBe(2);
|
||||||
|
expect(parsed!.durationMs).toBe(0);
|
||||||
|
expect(parsed!.totalCostUsd).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 1.2: parses stream with no turns and no result line", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({
|
||||||
|
type: "system",
|
||||||
|
session_id: "sess-no-turns",
|
||||||
|
model: "claude-opus-4",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const stdout = lines.join("\n");
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(stdout);
|
||||||
|
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.subtype).toBe("incomplete");
|
||||||
|
expect(parsed!.result).toBe("");
|
||||||
|
expect(parsed!.sessionId).toBe("sess-no-turns");
|
||||||
|
expect(parsed!.model).toBe("claude-opus-4");
|
||||||
|
expect(parsed!.turns).toHaveLength(0);
|
||||||
|
expect(parsed!.stopReason).toBe("incomplete_no_result_line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 1.3: returns null for completely empty output", () => {
|
||||||
|
const parsed1 = parseClaudeCodeStreamOutput("");
|
||||||
|
expect(parsed1).toBeNull();
|
||||||
|
|
||||||
|
const parsed2 = parseClaudeCodeStreamOutput(" \n \n ");
|
||||||
|
expect(parsed2).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 1.4: returns null for malformed JSON lines only", () => {
|
||||||
|
const stdout = "not json\n{broken json\n[invalid";
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(stdout);
|
||||||
|
expect(parsed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 6.1: extracts from last assistant text-only turn", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "First message" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "Last message" }] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.result).toBe("Last message");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 6.2: extracts from last assistant turn with tool calls", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Text with tools" },
|
||||||
|
{ type: "tool_use", name: "Bash", input: { command: "ls" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.result).toBe("Text with tools");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 6.3: returns empty string when no assistant turns", () => {
|
||||||
|
const lines = [JSON.stringify({ type: "system", session_id: "s1", model: "test" })];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Test 6.4: extracts from most recent assistant turn before tool_result", () => {
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "system", session_id: "s1", model: "test" }),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: { role: "assistant", content: [{ type: "text", text: "Before tool call" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "tool_result", content: "tool output" }] },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
expect(parsed!.result).toBe("Before tool call");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storeClaudeCodeDetail — incomplete results", () => {
|
||||||
|
test("Test 4.1: stores incomplete result as detail", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const incompleteParsed: ClaudeCodeParsedResult = {
|
||||||
|
type: "result",
|
||||||
|
subtype: "incomplete",
|
||||||
|
result: "Partial output",
|
||||||
|
sessionId: "sess-incomplete",
|
||||||
|
numTurns: 2,
|
||||||
|
totalCostUsd: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
model: "claude-sonnet-4.5",
|
||||||
|
stopReason: "incomplete_no_result_line",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadInputTokens: 0,
|
||||||
|
cacheCreationInputTokens: 0,
|
||||||
|
},
|
||||||
|
turns: [
|
||||||
|
{ index: 0, role: "assistant", content: "Turn 1", toolCalls: null },
|
||||||
|
{ index: 1, role: "assistant", content: "Partial output", toolCalls: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, incompleteParsed);
|
||||||
|
|
||||||
|
expect(detailHash).toHaveLength(13);
|
||||||
|
expect(output).toBe("Partial output");
|
||||||
|
expect(sessionId).toBe("sess-incomplete");
|
||||||
|
|
||||||
|
const node = await store.get(detailHash);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node!.payload.subtype).toBe("incomplete");
|
||||||
|
expect(node!.payload.stopReason).toBe("incomplete_no_result_line");
|
||||||
|
expect(node!.payload.turns).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("storeClaudeCodeRawOutput", () => {
|
describe("storeClaudeCodeRawOutput", () => {
|
||||||
test("stores raw text when JSON parsing fails", async () => {
|
test("stores raw text when JSON parsing fails", async () => {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }> {
|
function spawnClaude(
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(CLAUDE_COMMAND, args, {
|
const child = spawn(CLAUDE_COMMAND, args, {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
@@ -72,7 +74,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
|
|||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve({ stdout, stderr });
|
resolve({ stdout, stderr, exitCode: code });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
|
||||||
@@ -81,7 +83,9 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
function spawnClaudeRun(
|
||||||
|
prompt: string,
|
||||||
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
const args = [
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
prompt,
|
prompt,
|
||||||
@@ -101,7 +105,7 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
|
|||||||
function spawnClaudeResume(
|
function spawnClaudeResume(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
const args = [
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
message,
|
message,
|
||||||
@@ -122,6 +126,8 @@ function spawnClaudeResume(
|
|||||||
|
|
||||||
async function processClaudeOutput(
|
async function processClaudeOutput(
|
||||||
stdout: string,
|
stdout: string,
|
||||||
|
stderr: string,
|
||||||
|
exitCode: number | null,
|
||||||
store: Store,
|
store: Store,
|
||||||
assembledPrompt: string,
|
assembledPrompt: string,
|
||||||
): Promise<AgentRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
@@ -129,11 +135,25 @@ async function processClaudeOutput(
|
|||||||
|
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
|
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
|
||||||
|
|
||||||
|
// Log incomplete results for visibility
|
||||||
|
if (parsed.subtype === "incomplete") {
|
||||||
|
log(
|
||||||
|
"7NQW8R4P",
|
||||||
|
`Claude Code exited with incomplete output (no result line). Exit code: ${exitCode ?? "null"}, stderr: ${stderr.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { output, detailHash, sessionId, assembledPrompt };
|
return { output, detailHash, sessionId, assembledPrompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Truly unparseable output - provide enhanced error message
|
||||||
|
const exitInfo = exitCode !== null && exitCode !== 0 ? `Exit code: ${exitCode}\n` : "";
|
||||||
|
const stderrInfo = stderr.trim() !== "" ? `Stderr: ${stderr.slice(0, 200)}\n` : "";
|
||||||
|
const stdoutSnippet = stdout.slice(0, 200);
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Claude Code returned unparseable output (first 200 chars): ${stdout.slice(0, 200)}`,
|
`Claude Code exited without producing parseable output.\n${exitInfo}${stderrInfo}Stdout (first 200 chars): ${stdoutSnippet}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +167,8 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
|||||||
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
|
const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role);
|
||||||
if (cachedSessionId !== null) {
|
if (cachedSessionId !== null) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
const { stdout, stderr, exitCode } = await spawnClaudeResume(cachedSessionId, fullPrompt);
|
||||||
const result = await processClaudeOutput(stdout, ctx.store, fullPrompt);
|
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
|
||||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||||
}
|
}
|
||||||
@@ -162,8 +182,8 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await spawnClaudeRun(fullPrompt);
|
const { stdout, stderr, exitCode } = await spawnClaudeRun(fullPrompt);
|
||||||
const result = await processClaudeOutput(stdout, ctx.store, fullPrompt);
|
const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt);
|
||||||
if (result.sessionId !== undefined && result.sessionId !== "") {
|
if (result.sessionId !== undefined && result.sessionId !== "") {
|
||||||
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId);
|
||||||
}
|
}
|
||||||
@@ -175,8 +195,8 @@ async function continueClaudeCode(
|
|||||||
message: string,
|
message: string,
|
||||||
store: Store,
|
store: Store,
|
||||||
): Promise<AgentRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
const { stdout } = await spawnClaudeResume(sessionId, message);
|
const { stdout, stderr, exitCode } = await spawnClaudeResume(sessionId, message);
|
||||||
return processClaudeOutput(stdout, store, "");
|
return processClaudeOutput(stdout, stderr, exitCode, store, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
|
/** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type ParseState = {
|
|||||||
turns: ClaudeCodeTurnPayload[];
|
turns: ClaudeCodeTurnPayload[];
|
||||||
resultLine: Record<string, unknown> | null;
|
resultLine: Record<string, unknown> | null;
|
||||||
model: string;
|
model: string;
|
||||||
|
sessionId: string;
|
||||||
turnIndex: number;
|
turnIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ function processSystemLine(parsed: Record<string, unknown>, state: ParseState):
|
|||||||
if (typeof parsed.model === "string") {
|
if (typeof parsed.model === "string") {
|
||||||
state.model = parsed.model;
|
state.model = parsed.model;
|
||||||
}
|
}
|
||||||
|
if (typeof parsed.session_id === "string") {
|
||||||
|
state.sessionId = parsed.session_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
|
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
@@ -124,8 +128,52 @@ function processLine(line: string, state: ParseState): void {
|
|||||||
else if (type === "result") state.resultLine = parsed;
|
else if (type === "result") state.resultLine = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract output text from the last assistant turn.
|
||||||
|
* Used for best-effort extraction when no result line is present.
|
||||||
|
*/
|
||||||
|
function extractLastAssistantContent(turns: ClaudeCodeTurnPayload[]): string {
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
const turn = turns[i];
|
||||||
|
if (turn !== undefined && turn.role === "assistant" && turn.content !== "") {
|
||||||
|
return turn.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
||||||
if (state.resultLine === null) return null;
|
// Handle incomplete result (no result line)
|
||||||
|
if (state.resultLine === null) {
|
||||||
|
// Need at least a session_id from system line to be parseable
|
||||||
|
if (state.sessionId === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort extraction: get output from last assistant turn
|
||||||
|
const result = extractLastAssistantContent(state.turns);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "result",
|
||||||
|
subtype: "incomplete",
|
||||||
|
result,
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
numTurns: state.turns.length,
|
||||||
|
totalCostUsd: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
model: state.model,
|
||||||
|
stopReason: "incomplete_no_result_line",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadInputTokens: 0,
|
||||||
|
cacheCreationInputTokens: 0,
|
||||||
|
},
|
||||||
|
turns: state.turns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle complete result (has result line)
|
||||||
const sessionId = state.resultLine.session_id;
|
const sessionId = state.resultLine.session_id;
|
||||||
const result = state.resultLine.result;
|
const result = state.resultLine.result;
|
||||||
const subtype = state.resultLine.subtype;
|
const subtype = state.resultLine.subtype;
|
||||||
@@ -159,7 +207,13 @@ function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
|||||||
*/
|
*/
|
||||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||||
const lines = stdout.trim().split("\n");
|
const lines = stdout.trim().split("\n");
|
||||||
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
|
const state: ParseState = {
|
||||||
|
turns: [],
|
||||||
|
resultLine: null,
|
||||||
|
model: "",
|
||||||
|
sessionId: "",
|
||||||
|
turnIndex: 0,
|
||||||
|
};
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
processLine(line, state);
|
processLine(line, state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
|
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget" | "incomplete";
|
||||||
|
|
||||||
/** A single tool call within an assistant turn. */
|
/** A single tool call within an assistant turn. */
|
||||||
export type ClaudeCodeToolCall = {
|
export type ClaudeCodeToolCall = {
|
||||||
|
|||||||
Reference in New Issue
Block a user