From a2874c042666d73f5dc0cb19d7e5a548dbb0b6ec Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sun, 26 Apr 2026 01:54:35 +0000 Subject: [PATCH] fix: detect mid-stream truncation and emit claude_truncated error code (FAR-95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude produces assistant content (output_tokens > 0) but the stream ends without a result event, classify the run as truncated mid-stream rather than falling through to the generic "did not produce a result — check API credentials" message. The misleading hint pointed operators at auth/model config when the real cause was pod termination, OOMKill, or CLI crash. Co-Authored-By: Paperclip --- src/server/execute.test.ts | 38 ++++++++++++++++++++++++++++++++ src/server/execute.ts | 12 +++++++++++ src/server/parse.test.ts | 44 ++++++++++++++++++++++++++++++++++++++ src/server/parse.ts | 10 +++++++++ 4 files changed, 104 insertions(+) diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index 1aefa30..0aaf02f 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -994,6 +994,44 @@ describe("execute: happy path", () => { expect(result.errorMessage).toContain("output_tokens: 0"); }); + it("returns claude_truncated when assistant produced content but no result event arrived (FAR-95)", async () => { + const truncatedOutput = [ + JSON.stringify({ type: "system", subtype: "init", model: "claude-opus-4-7", session_id: "sess_trunc" }), + JSON.stringify({ + type: "assistant", + session_id: "sess_trunc", + message: { + id: "msg_trunc", + stop_reason: null, + usage: { input_tokens: 1, output_tokens: 35, cache_creation_input_tokens: 523, cache_read_input_tokens: 46295 }, + content: [{ type: "tool_use", id: "tool_1", name: "Bash", input: { command: "echo hi" } }], + }, + }), + JSON.stringify({ + type: "user", + message: { role: "user", content: [{ tool_use_id: "tool_1", type: "tool_result", content: "hi", is_error: false }] }, + }), + ].join("\n") + "\n"; + + mockLogFn.mockImplementation( + async (_ns: string, _pod: string, _ctr: string, writable: Writable) => { + writable.write(truncatedOutput); + }, + ); + mockCoreListPods.mockResolvedValue({ + items: [{ metadata: { name: "pod-abc" }, status: { containerStatuses: [{ name: "claude", state: { terminated: { exitCode: 137 } } }] } }], + }); + + const executePromise = execute(makeCtx()); + await vi.advanceTimersByTimeAsync(3_100); + const result = await executePromise; + + expect(result.errorCode).toBe("claude_truncated"); + expect(result.errorMessage).toContain("truncated mid-stream"); + expect(result.errorMessage).toContain("claude-opus-4-7"); + expect(result.errorMessage).toContain("exit code 137"); + }); + it("reconnects log stream and logs status when job completion takes > 3s", async () => { // Make waitForJobCompletion take 4s so the 3s stream reconnect fires first. // timeoutSec=4, graceSec=0 → completionTimeoutMs=4000. diff --git a/src/server/execute.ts b/src/server/execute.ts index 4920109..ea70f50 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -1367,6 +1367,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise0 has no result (FAR-95)", () => { + const initLine = JSON.stringify({ type: "system", subtype: "init", model: "claude-opus-4-7", session_id: "sess_1" }); + const assistantEvent = JSON.stringify({ + type: "assistant", + session_id: "sess_1", + message: { + id: "msg_abc", + stop_reason: null, + usage: { input_tokens: 1, output_tokens: 35, cache_creation_input_tokens: 523, cache_read_input_tokens: 46295 }, + content: [{ type: "tool_use", id: "tool_1", name: "Bash", input: { command: "echo hi" } }], + }, + }); + const result = parseClaudeStreamJson([initLine, assistantEvent].join("\n")); + expect(result.truncatedMidStream).toBe(true); + expect(result.llmApiEmptyResponse).toBe(false); + expect(result.resultJson).toBeNull(); + }); + + it("clears truncatedMidStream when a result event follows assistant content", () => { + const assistantEvent = JSON.stringify({ + type: "assistant", + message: { stop_reason: null, usage: { output_tokens: 35 }, content: [] }, + }); + const resultEvent = JSON.stringify({ + type: "result", + result: "Done", + subtype: "stop", + total_cost_usd: 0.001, + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0 }, + }); + const result = parseClaudeStreamJson([assistantEvent, resultEvent].join("\n")); + expect(result.truncatedMidStream).toBe(false); + expect(result.resultJson).not.toBeNull(); + }); + + it("does not set truncatedMidStream when assistant has output_tokens=0", () => { + const assistantEvent = JSON.stringify({ + type: "assistant", + message: { stop_reason: null, usage: { output_tokens: 0 }, content: [] }, + }); + const result = parseClaudeStreamJson(assistantEvent); + expect(result.truncatedMidStream).toBe(false); + }); + it("sets llmApiEmptyResponse=false for normal result", () => { const resultEvent = JSON.stringify({ type: "result", diff --git a/src/server/parse.ts b/src/server/parse.ts index 5d900b7..9bb9348 100644 --- a/src/server/parse.ts +++ b/src/server/parse.ts @@ -19,6 +19,10 @@ export function parseClaudeStreamJson(stdout: string) { // with no subsequent result event — indicates the upstream LLM API returned // an empty/malformed response (e.g. MiniMax degraded performance). let llmApiEmptyResponse = false; + // Set when an assistant event with output_tokens > 0 was seen but no result + // event arrived — indicates the run was truncated mid-stream (pod terminated, + // OOMKill, or claude CLI crash after producing content). + let assistantContentSeen = false; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -49,6 +53,9 @@ export function parseClaudeStreamJson(stdout: string) { if (stopReason === null && outputTokens === 0) { llmApiEmptyResponse = true; } + if (outputTokens > 0) { + assistantContentSeen = true; + } for (let i = 0; i < content.length; i++) { const entry = content[i]; @@ -72,6 +79,7 @@ export function parseClaudeStreamJson(stdout: string) { if (type === "result") { finalResult = event; llmApiEmptyResponse = false; // result event means Claude completed normally + assistantContentSeen = false; // result event means stream was not truncated sessionId = asString(event.session_id, sessionId ?? "") || sessionId; } } @@ -85,6 +93,7 @@ export function parseClaudeStreamJson(stdout: string) { summary: assistantTexts.join("\n\n").trim(), resultJson: null as Record | null, llmApiEmptyResponse, + truncatedMidStream: assistantContentSeen, }; } @@ -106,6 +115,7 @@ export function parseClaudeStreamJson(stdout: string) { summary, resultJson: finalResult, llmApiEmptyResponse: false, + truncatedMidStream: false, }; }