Merge pull request 'fix(agent-claude-code): handle missing result line gracefully' (#576) from fix/574-silent-fail-handling into main
CI / check (push) Successful in 2m18s

This commit was merged in pull request #576.
This commit is contained in:
2026-05-30 05:52:54 +00:00
4 changed files with 261 additions and 14 deletions
@@ -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();
@@ -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<AgentRunResult> {
@@ -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<AgentRunResult> {
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<AgentRunResult> {
}
}
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<AgentRunResult> {
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. */
@@ -71,6 +71,7 @@ type ParseState = {
turns: ClaudeCodeTurnPayload[];
resultLine: Record<string, unknown> | null;
model: string;
sessionId: string;
turnIndex: number;
};
@@ -78,6 +79,9 @@ function processSystemLine(parsed: Record<string, unknown>, 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<string, unknown>, 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);
}
@@ -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 = {