From 3dc6963dfbab148d308357d5371fd5e2a1a0f544 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 31 Oct 2025 18:25:30 -0700 Subject: [PATCH] fix: improve error handling in interactive and headless modes (#48) Co-authored-by: Letta --- src/cli/App.tsx | 12 ++++++-- src/headless.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index fbf049f..b044646 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -42,6 +42,7 @@ import { type Buffers, createBuffers, type Line, + markIncompleteToolsAsCancelled, onChunk, toLines, } from "./helpers/accumulator"; @@ -514,13 +515,20 @@ export default function App({ continue; // Loop continues naturally } - // Unexpected stop reason - // TODO: For error stop reasons (error, llm_api_error, etc.), fetch step details + // TODO: for error stop reasons, fetch step details // using lastRunId to get full error message from step.errorData // Example: client.runs.steps.list(lastRunId, { limit: 1, order: "desc" }) // Then display step.errorData.message or full error details instead of generic message + + // Unexpected stop reason (error, llm_api_error, etc.) + // Mark incomplete tool calls as finished to prevent stuck blinking UI + markIncompleteToolsAsCancelled(buffersRef.current); + + // Show stop reason (mid-stream errors should already be in buffers) appendError(`Unexpected stop reason: ${stopReason}`); + setStreaming(false); + refreshDerived(); return; } } catch (e) { diff --git a/src/headless.ts b/src/headless.ts index 700924b..91c0325 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -260,7 +260,44 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { const autoApprovalEmitted = new Set(); let _lastApprovalId: string | null = null; + // Track all run_ids seen during this turn + const runIds = new Set(); + for await (const chunk of stream) { + // Track run_id if present + if ("run_id" in chunk && chunk.run_id) { + runIds.add(chunk.run_id); + } + + // Detect mid-stream errors (errors without message_type) + const chunkWithError = chunk as typeof chunk & { + error?: { message?: string; detail?: string }; + }; + if (chunkWithError.error && !chunk.message_type) { + // Emit as error event + const errorMsg = + chunkWithError.error.message || "An error occurred"; + const errorDetail = chunkWithError.error.detail || ""; + const fullErrorText = errorDetail + ? `${errorMsg}: ${errorDetail}` + : errorMsg; + + console.log( + JSON.stringify({ + type: "error", + message: fullErrorText, + detail: errorDetail, + }), + ); + + // Still accumulate for tracking + const { onChunk: accumulatorOnChunk } = await import( + "./cli/helpers/accumulator" + ); + accumulatorOnChunk(buffers, chunk); + continue; + } + // Detect server conflict due to pending approval; handle it and retry const errObj = (chunk as unknown as { error?: { detail?: string } }) .error; @@ -509,12 +546,32 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { continue; } - // Unexpected stop reason - // TODO: For error stop reasons (error, llm_api_error, etc.), fetch step details - // using lastRunId to get full error message from step.errorData - // Example: client.runs.steps.list(lastRunId, { limit: 1, order: "desc" }) - // Then display step.errorData.message or full error details instead of generic message - console.error(`Unexpected stop reason: ${stopReason}`); + // Unexpected stop reason (error, llm_api_error, etc.) + // Extract error details from buffers if available + const errorLines = toLines(buffers).filter( + (line) => line.kind === "error", + ); + const errorMessages = errorLines + .map((line) => ("text" in line ? line.text : "")) + .filter(Boolean); + + const errorMessage = + errorMessages.length > 0 + ? errorMessages.join("; ") + : `Unexpected stop reason: ${stopReason}`; + + if (outputFormat === "stream-json") { + // Emit error event + console.log( + JSON.stringify({ + type: "error", + message: errorMessage, + stop_reason: stopReason, + }), + ); + } else { + console.error(errorMessage); + } process.exit(1); } } catch (error) { @@ -558,6 +615,17 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { } else if (outputFormat === "stream-json") { // Output final result event const stats = sessionStats.getSnapshot(); + + // Collect all run_ids from buffers + const allRunIds = new Set(); + for (const line of toLines(buffers)) { + // Extract run_id from any line that might have it + // This is a fallback in case we missed any during streaming + if ("run_id" in line && typeof line.run_id === "string") { + allRunIds.add(line.run_id); + } + } + const resultEvent = { type: "result", subtype: "success", @@ -567,6 +635,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) { num_turns: stats.usage.stepCount, result: resultText, agent_id: agent.id, + run_ids: Array.from(allRunIds), usage: { prompt_tokens: stats.usage.promptTokens, completion_tokens: stats.usage.completionTokens,