fix: prevent stale stream updates after interrupt (#318)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user