fix: prevent approval from reappearing after interrupt during execution (#571)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-16 19:14:39 -08:00
committed by GitHub
parent 0750281d13
commit 28943757a3
4 changed files with 65 additions and 6 deletions

View File

@@ -689,6 +689,8 @@ export default function App({
reason: string;
}>
>([]);
const executingToolCallIdsRef = useRef<string[]>([]);
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;
}
},
[

View File

@@ -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 (
<Box>
<Text dimColor>{` ⎿ Running... (${elapsed}s)`}</Text>
</Box>
);
}
return (

View File

@@ -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(),
},
}
: {}),
});
}
}
}

View File

@@ -57,6 +57,7 @@ function spawnWithLauncher(
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let timedOut = false;
let killTimer: ReturnType<typeof setTimeout> | 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);
}