From 28943757a30abcfa0949e02836bfc96e8b566c39 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 16 Jan 2026 19:14:39 -0800 Subject: [PATCH] fix: prevent approval from reappearing after interrupt during execution (#571) Co-authored-by: Letta --- src/cli/App.tsx | 29 +++++++++++++++++-- src/cli/components/StreamingOutputDisplay.tsx | 7 +++-- src/cli/helpers/accumulator.ts | 19 +++++++++++- src/tools/impl/Bash.ts | 16 ++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8d927f8..aa26df2 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -689,6 +689,8 @@ export default function App({ reason: string; }> >([]); + const executingToolCallIdsRef = useRef([]); + const interruptQueuedRef = useRef(false); // Bash mode: cache bash commands to prefix next user message // Use ref instead of state to avoid stale closure issues in onSubmit @@ -2718,6 +2720,20 @@ export default function App({ if (isExecutingTool && toolAbortControllerRef.current) { toolAbortControllerRef.current.abort(); + if (executingToolCallIdsRef.current.length > 0) { + const interruptedResults = executingToolCallIdsRef.current.map( + (toolCallId) => ({ + type: "tool" as const, + tool_call_id: toolCallId, + tool_return: INTERRUPTED_BY_USER, + status: "error" as const, + }), + ); + setQueuedApprovalResults(interruptedResults); + executingToolCallIdsRef.current = []; + interruptQueuedRef.current = true; + } + // ALSO abort the main stream - don't leave it running buffersRef.current.abortGeneration = (buffersRef.current.abortGeneration || 0) + 1; @@ -5683,6 +5699,7 @@ DO NOT respond to these messages or otherwise consider them in your response unl approvals: queuedApprovalResults, }); setQueuedApprovalResults(null); + interruptQueuedRef.current = false; } initialInput.push({ @@ -5804,6 +5821,10 @@ DO NOT respond to these messages or otherwise consider them in your response unl ...(additionalDecision ? [additionalDecision] : []), ]; + executingToolCallIdsRef.current = allDecisions + .filter((decision) => decision.type === "approve") + .map((decision) => decision.approval.toolCallId); + // Set phase to "running" for all approved tools setToolCallsRunning( buffersRef.current, @@ -5898,9 +5919,9 @@ DO NOT respond to these messages or otherwise consider them in your response unl const userCancelled = userCancelledRef.current; if (wasAborted || userCancelled) { - // Queue results to send alongside the next user message (if not cancelled entirely) - // Don't queue if ESC was pressed - interrupted results would cause desync errors - if (!userCancelled) { + // Queue results to send alongside the next user message so the backend + // doesn't keep requesting the same approvals after an interrupt. + if (!interruptQueuedRef.current) { setQueuedApprovalResults(allResults as ApprovalResult[]); } setStreaming(false); @@ -5921,6 +5942,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl // Always release the execution guard, even if an error occurred setIsExecutingTool(false); toolAbortControllerRef.current = null; + executingToolCallIdsRef.current = []; + interruptQueuedRef.current = false; } }, [ diff --git a/src/cli/components/StreamingOutputDisplay.tsx b/src/cli/components/StreamingOutputDisplay.tsx index 3ec5daf..920cb7c 100644 --- a/src/cli/components/StreamingOutputDisplay.tsx +++ b/src/cli/components/StreamingOutputDisplay.tsx @@ -23,10 +23,13 @@ export const StreamingOutputDisplay = memo( const { tailLines, totalLineCount } = streaming; const hiddenCount = Math.max(0, totalLineCount - tailLines.length); - // No output yet - don't show anything const firstLine = tailLines[0]; if (!firstLine) { - return null; + return ( + + {` ⎿ Running... (${elapsed}s)`} + + ); } return ( diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 0c1209d..bf3153f 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -6,6 +6,7 @@ import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages"; import { INTERRUPTED_BY_USER } from "../../constants"; +import { isShellTool } from "./toolNameMapping"; // Constants for streaming output const MAX_TAIL_LINES = 5; @@ -610,7 +611,23 @@ export function setToolCallsRunning(b: Buffers, toolCallIds: string[]): void { if (lineId) { const line = b.byId.get(lineId); if (line && line.kind === "tool_call") { - b.byId.set(lineId, { ...line, phase: "running" }); + const shouldSeedStreaming = + line.name && isShellTool(line.name) && !line.streaming; + b.byId.set(lineId, { + ...line, + phase: "running", + ...(shouldSeedStreaming + ? { + streaming: { + tailLines: [], + partialLine: "", + partialIsStderr: false, + totalLineCount: 0, + startTime: Date.now(), + }, + } + : {}), + }); } } } diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts index 8dd25a7..33c28d5 100644 --- a/src/tools/impl/Bash.ts +++ b/src/tools/impl/Bash.ts @@ -57,6 +57,7 @@ function spawnWithLauncher( const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let timedOut = false; + let killTimer: ReturnType | null = null; const timeoutId = setTimeout(() => { timedOut = true; @@ -65,6 +66,13 @@ function spawnWithLauncher( const abortHandler = () => { childProcess.kill("SIGTERM"); + if (!killTimer) { + killTimer = setTimeout(() => { + if (childProcess.exitCode === null && !childProcess.killed) { + childProcess.kill("SIGKILL"); + } + }, 2000); + } }; if (options.signal) { options.signal.addEventListener("abort", abortHandler, { once: true }); @@ -82,6 +90,10 @@ function spawnWithLauncher( childProcess.on("error", (err) => { clearTimeout(timeoutId); + if (killTimer) { + clearTimeout(killTimer); + killTimer = null; + } if (options.signal) { options.signal.removeEventListener("abort", abortHandler); } @@ -90,6 +102,10 @@ function spawnWithLauncher( childProcess.on("close", (code) => { clearTimeout(timeoutId); + if (killTimer) { + clearTimeout(killTimer); + killTimer = null; + } if (options.signal) { options.signal.removeEventListener("abort", abortHandler); }