From 53fa4d89723b4fb10ed8df98df4146efc662b579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 30 May 2026 05:37:09 +0000 Subject: [PATCH] fix(agent-claude-code): handle missing result line gracefully Handle the case where Claude Code exits without producing a result line (timeout, OOM, signal kill). Previously returned null and threw an error; now returns incomplete result with best-effort output extraction. Changes: - Add "incomplete" as new ClaudeCodeResultSubtype value - Extract output from last assistant turn when no result line exists - Enhanced error messages distinguish incomplete vs unparseable output - Store incomplete results in CAS with appropriate metadata - Add 10 comprehensive test cases for incomplete result handling Fixes #574 Co-Authored-By: Claude Opus 4.6 --- .../__tests__/session-detail.test.ts | 173 ++++++++++++++++++ .../src/claude-code.ts | 42 +++-- .../src/session-detail.ts | 58 +++++- .../workflow-agent-claude-code/src/types.ts | 2 +- 4 files changed, 261 insertions(+), 14 deletions(-) diff --git a/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts index efd4922..03d1d2e 100644 --- a/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts +++ b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts @@ -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", () => { test("stores raw text when JSON parsing fails", async () => { const store = createMemoryStore(); diff --git a/packages/workflow-agent-claude-code/src/claude-code.ts b/packages/workflow-agent-claude-code/src/claude-code.ts index cdbe191..8996f46 100644 --- a/packages/workflow-agent-claude-code/src/claude-code.ts +++ b/packages/workflow-agent-claude-code/src/claude-code.ts @@ -48,7 +48,9 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string { 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) => { const child = spawn(CLAUDE_COMMAND, args, { env: process.env, @@ -72,7 +74,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string } child.on("close", (code) => { if (code === 0) { - resolve({ stdout, stderr }); + resolve({ stdout, stderr, exitCode: code }); return; } 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 = [ "-p", prompt, @@ -101,7 +105,7 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin function spawnClaudeResume( sessionId: string, message: string, -): Promise<{ stdout: string; stderr: string }> { +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { const args = [ "-p", message, @@ -122,6 +126,8 @@ function spawnClaudeResume( async function processClaudeOutput( stdout: string, + stderr: string, + exitCode: number | null, store: Store, assembledPrompt: string, ): Promise { @@ -129,11 +135,25 @@ async function processClaudeOutput( if (parsed !== null) { 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 }; } + // 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( - `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 { const cachedSessionId = await getCachedSessionId("claude-code", ctx.threadId, ctx.role); if (cachedSessionId !== null) { try { - const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt); - const result = await processClaudeOutput(stdout, ctx.store, fullPrompt); + const { stdout, stderr, exitCode } = await spawnClaudeResume(cachedSessionId, fullPrompt); + const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt); if (result.sessionId !== undefined && result.sessionId !== "") { await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId); } @@ -162,8 +182,8 @@ async function runClaudeCode(ctx: AgentContext): Promise { } } - const { stdout } = await spawnClaudeRun(fullPrompt); - const result = await processClaudeOutput(stdout, ctx.store, fullPrompt); + const { stdout, stderr, exitCode } = await spawnClaudeRun(fullPrompt); + const result = await processClaudeOutput(stdout, stderr, exitCode, ctx.store, fullPrompt); if (result.sessionId !== undefined && result.sessionId !== "") { await setCachedSessionId("claude-code", ctx.threadId, ctx.role, result.sessionId); } @@ -175,8 +195,8 @@ async function continueClaudeCode( message: string, store: Store, ): Promise { - const { stdout } = await spawnClaudeResume(sessionId, message); - return processClaudeOutput(stdout, store, ""); + const { stdout, stderr, exitCode } = await spawnClaudeResume(sessionId, message); + return processClaudeOutput(stdout, stderr, exitCode, store, ""); } /** Agent CLI factory: parses argv, runs Claude Code, extracts output, writes StepNode. */ diff --git a/packages/workflow-agent-claude-code/src/session-detail.ts b/packages/workflow-agent-claude-code/src/session-detail.ts index 941fdf1..0b0ea36 100644 --- a/packages/workflow-agent-claude-code/src/session-detail.ts +++ b/packages/workflow-agent-claude-code/src/session-detail.ts @@ -71,6 +71,7 @@ type ParseState = { turns: ClaudeCodeTurnPayload[]; resultLine: Record | null; model: string; + sessionId: string; turnIndex: number; }; @@ -78,6 +79,9 @@ function processSystemLine(parsed: Record, state: ParseState): if (typeof parsed.model === "string") { state.model = parsed.model; } + if (typeof parsed.session_id === "string") { + state.sessionId = parsed.session_id; + } } function processAssistantLine(parsed: Record, state: ParseState): void { @@ -124,8 +128,52 @@ function processLine(line: string, state: ParseState): void { 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 { - 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 result = state.resultLine.result; const subtype = state.resultLine.subtype; @@ -159,7 +207,13 @@ function assembleResult(state: ParseState): ClaudeCodeParsedResult | null { */ export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null { 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) { processLine(line, state); } diff --git a/packages/workflow-agent-claude-code/src/types.ts b/packages/workflow-agent-claude-code/src/types.ts index 068effc..77db4ea 100644 --- a/packages/workflow-agent-claude-code/src/types.ts +++ b/packages/workflow-agent-claude-code/src/types.ts @@ -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. */ export type ClaudeCodeToolCall = {