fix: prevent stale stream updates after interrupt (#318)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-18 23:05:19 -08:00
committed by GitHub
parent 9d19565fd2
commit 66c4842375
3 changed files with 19 additions and 5 deletions

View File

@@ -654,7 +654,11 @@ export default function App({
buffersRef.current.pendingRefresh = true;
setTimeout(() => {
buffersRef.current.pendingRefresh = false;
refreshDerived();
// Skip refresh if stream was interrupted - prevents stale updates appearing
// after user cancels. Normal stream completion still renders (interrupted=false).
if (!buffersRef.current.interrupted) {
refreshDerived();
}
}, 16); // ~60fps
}
}, [refreshDerived]);
@@ -828,6 +832,8 @@ export default function App({
// Clear any stale pending tool calls from previous turns
// If we're sending a new message, old pending state is no longer relevant
markIncompleteToolsAsCancelled(buffersRef.current);
// Reset interrupted flag since we're starting a fresh stream
buffersRef.current.interrupted = false;
// Clear completed subagents from the UI when starting a new turn
clearCompletedSubagents();
@@ -3013,6 +3019,8 @@ ${recentCommits}
// Reset token counter for this turn (only count the agent's response)
buffersRef.current.tokenCount = 0;
// Clear interrupted flag from previous turn
buffersRef.current.interrupted = false;
// Rotate to a new thinking message for this turn
setThinkingMessage(getRandomThinkingVerb());
// Show streaming state immediately for responsiveness
@@ -3412,6 +3420,8 @@ ${recentCommits}
// Show "thinking" state and lock input while executing approved tools client-side
setStreaming(true);
// Ensure interrupted flag is cleared for this execution
buffersRef.current.interrupted = false;
const approvalAbortController = new AbortController();
toolAbortControllerRef.current = approvalAbortController;

View File

@@ -62,6 +62,7 @@ export type Buffers = {
toolCallIdToLineId: Map<string, string>;
lastOtid: string | null; // Track the last otid to detect transitions
pendingRefresh?: boolean; // Track throttled refresh state
interrupted?: boolean; // Track if stream was interrupted by user (skip stale refreshes)
usage: {
promptTokens: number;
completionTokens: number;
@@ -162,6 +163,9 @@ export function markCurrentLineAsFinished(b: Buffers) {
* This prevents blinking tool calls from staying in progress state.
*/
export function markIncompleteToolsAsCancelled(b: Buffers) {
// Mark buffer as interrupted to skip stale throttled refreshes
b.interrupted = true;
for (const [id, line] of b.byId.entries()) {
if (line.kind === "tool_call" && line.phase !== "finished") {
const updatedLine = {

View File

@@ -141,10 +141,7 @@ export async function drainStream(
}
}
onChunk(buffers, chunk);
queueMicrotask(refresh);
// Check abort signal again after processing chunk (for eager cancellation)
// Check abort signal before processing - don't add data after interrupt
if (abortSignal?.aborted) {
stopReason = "cancelled";
markIncompleteToolsAsCancelled(buffers);
@@ -152,6 +149,9 @@ export async function drainStream(
break;
}
onChunk(buffers, chunk);
queueMicrotask(refresh);
if (chunk.message_type === "stop_reason") {
stopReason = chunk.stop_reason;
// Continue reading stream to get usage_statistics that may come after