diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 4907ed0..4054450 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1737,7 +1737,11 @@ export default function App({ // If we're sending a new message, old pending state is no longer relevant // Pass false to avoid setting interrupted=true, which causes race conditions // with concurrent processConversation calls reading the flag - markIncompleteToolsAsCancelled(buffersRef.current, false); + markIncompleteToolsAsCancelled( + buffersRef.current, + false, + "internal_cancel", + ); // Reset interrupted flag since we're starting a fresh stream buffersRef.current.interrupted = false; @@ -2169,7 +2173,11 @@ export default function App({ abortControllerRef.current?.signal.aborted ) { setStreaming(false); - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); refreshDerived(); return; } @@ -2422,7 +2430,11 @@ export default function App({ queueApprovalResults(allResults, autoAllowedMetadata); } setStreaming(false); - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); refreshDerived(); return; } @@ -2540,7 +2552,11 @@ export default function App({ abortControllerRef.current?.signal.aborted ) { setStreaming(false); - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); refreshDerived(); return; } @@ -2762,7 +2778,11 @@ export default function App({ llmApiErrorRetriesRef.current = 0; // Mark incomplete tool calls as finished to prevent stuck blinking UI - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "stream_error", + ); // Track the error in telemetry telemetry.trackError( @@ -2847,7 +2867,11 @@ export default function App({ } } catch (e) { // Mark incomplete tool calls as cancelled to prevent stuck blinking UI - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + e instanceof APIUserAbortError ? "user_interrupt" : "stream_error", + ); // If using eager cancel and this is an abort error, silently ignore it // The user already got "Stream interrupted by user" feedback from handleInterrupt @@ -2979,7 +3003,11 @@ export default function App({ // ALSO abort the main stream - don't leave it running buffersRef.current.abortGeneration = (buffersRef.current.abortGeneration || 0) + 1; - const toolsCancelled = markIncompleteToolsAsCancelled(buffersRef.current); + const toolsCancelled = markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); // Mark any running subagents as interrupted interruptActiveSubagents(INTERRUPTED_BY_USER); @@ -3029,7 +3057,11 @@ export default function App({ // This ensures onChunk and other guards see interrupted=true immediately. buffersRef.current.abortGeneration = (buffersRef.current.abortGeneration || 0) + 1; - const toolsCancelled = markIncompleteToolsAsCancelled(buffersRef.current); + const toolsCancelled = markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); // Mark any running subagents as interrupted interruptActiveSubagents(INTERRUPTED_BY_USER); @@ -5931,7 +5963,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl queueApprovalResults(queuedResults, autoAllowedMetadata); } setStreaming(false); - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); refreshDerived(); return { submitted: false }; } @@ -6174,7 +6210,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl queueApprovalResults(queuedResults, autoAllowedMetadata); } setStreaming(false); - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "user_interrupt", + ); refreshDerived(); return { submitted: false }; } @@ -6783,7 +6823,7 @@ DO NOT respond to these messages or otherwise consider them in your response unl queueApprovalResults(denialResults); // Mark the pending approval tool calls as cancelled in the buffers - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled(buffersRef.current, true, "approval_cancel"); refreshDerived(); // Clear all approval state @@ -7340,7 +7380,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl queueApprovalResults(denialResults); // Mark tool as cancelled in buffers - markIncompleteToolsAsCancelled(buffersRef.current); + markIncompleteToolsAsCancelled( + buffersRef.current, + true, + "internal_cancel", + ); refreshDerived(); // Clear all approval state (same as handleCancelApprovals) diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index bf3153f..c78338e 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -272,9 +272,23 @@ export function markCurrentLineAsFinished(b: Buffers) { * with concurrent processConversation calls reading the flag. * @returns true if any tool calls were marked as cancelled */ +export type CancelReason = + | "user_interrupt" + | "stream_error" + | "internal_cancel" + | "approval_cancel"; + +const CANCEL_REASON_TEXT: Record = { + user_interrupt: INTERRUPTED_BY_USER, + stream_error: "Stream error", + internal_cancel: "Cancelled", + approval_cancel: "Approval cancelled", +}; + export function markIncompleteToolsAsCancelled( b: Buffers, setInterruptedFlag = true, + reason: CancelReason = "internal_cancel", ): boolean { // Mark buffer as interrupted to skip stale throttled refreshes // (only when actually interrupting, not when clearing stale state at startup) @@ -289,7 +303,7 @@ export function markIncompleteToolsAsCancelled( ...line, phase: "finished" as const, resultOk: false, - resultText: INTERRUPTED_BY_USER, + resultText: CANCEL_REASON_TEXT[reason], }; b.byId.set(id, updatedLine); anyToolsCancelled = true; diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index a9719a6..e0498f1 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -101,7 +101,7 @@ export async function drainStream( // Check if stream was aborted if (abortSignal?.aborted) { stopReason = "cancelled"; - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "user_interrupt"); queueMicrotask(refresh); break; } @@ -135,7 +135,7 @@ export async function drainStream( // Check abort signal before processing - don't add data after interrupt if (abortSignal?.aborted) { stopReason = "cancelled"; - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "user_interrupt"); queueMicrotask(refresh); break; } @@ -172,7 +172,7 @@ export async function drainStream( // Set error stop reason so drainStreamWithResume can try to reconnect stopReason = "error"; - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "stream_error"); queueMicrotask(refresh); } finally { // Clean up abort listener @@ -189,7 +189,7 @@ export async function drainStream( // (SDK returns gracefully on abort), mark as cancelled if (abortedViaListener && !stopReason) { stopReason = "cancelled"; - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "user_interrupt"); queueMicrotask(refresh); } @@ -200,7 +200,7 @@ export async function drainStream( // Mark incomplete tool calls as cancelled if stream was cancelled if (stopReason === "cancelled") { - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "user_interrupt"); } // Mark the final line as finished now that stream has ended diff --git a/src/headless.ts b/src/headless.ts index fe1a70d..1515058 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1460,7 +1460,7 @@ export async function handleHeadlessCommand( } // Mark incomplete tool calls as cancelled to prevent stuck state - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "stream_error"); // Extract error details from buffers if available const errorLines = toLines(buffers).filter( @@ -1518,7 +1518,7 @@ export async function handleHeadlessCommand( } } catch (error) { // Mark incomplete tool calls as cancelled - markIncompleteToolsAsCancelled(buffers); + markIncompleteToolsAsCancelled(buffers, true, "stream_error"); // Use comprehensive error formatting (same as TUI mode) const errorDetails = formatErrorDetails(error, agent.id);