fix: use context-accurate cancel labels instead of hardcoded "Interrupted by user" (#598)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-19 15:51:04 -08:00
committed by GitHub
parent 6dcb31e6f3
commit 200f26250a
4 changed files with 78 additions and 20 deletions

View File

@@ -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)

View File

@@ -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<CancelReason, string> = {
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;

View File

@@ -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

View File

@@ -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);