Commit 64be95b16 for llama.cpp
commit 64be95b163fe3a80327fad1ab84dc162302cf235
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Mon Nov 2 12:34:00 2026 +0100
ui: show interrupted state for tool calls that never returned (#31314)
* ui: show interrupted state for tool calls that never returned
The chat-level streaming flag was applied to every assistant message, so a
tool call left without a result in an earlier message kept spinning while a
newer reply streamed. Scope streaming to the last assistant message and mark
finished calls without a result as interrupted.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: mark a shell command stopped before any output as interrupted
exec_shell_command creates its result message before running, so stopping
it mid-run leaves an empty result instead of none.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
index 665238a29..a67de857c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
@@ -8,7 +8,7 @@
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import ToolCallBlock from './ToolCallBlock.svelte';
- import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
+ import { AlertTriangle, Check, CircleStop, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AgenticSectionType, AttachmentType } from '$lib/enums';
@@ -229,6 +229,7 @@
{/snippet}
<ToolCallBlock
+ emptyResultIsInterrupted
extraLiveStreaming={isLive}
{isStreaming}
meta={execShellMeta ? { errorMessage: execShellError } : null}
@@ -248,6 +249,12 @@
<Loader2 class="h-3 w-3 animate-spin" />
Executing command...
</div>
+ {:else if ctx.isInterrupted}
+ <div class="flex items-start gap-2 text-xs text-muted-foreground/70 italic">
+ <CircleStop class="mt-0.5 h-3 w-3 shrink-0" />
+
+ <span>Command interrupted before it returned output</span>
+ </div>
{:else if execShellError}
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
index 16dd6e108..461ffe6f7 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
@@ -25,6 +25,7 @@
isPending: boolean;
isStreamingCall: boolean;
isCodeStreaming: boolean;
+ isInterrupted: boolean;
}
interface Props {
@@ -50,6 +51,12 @@
* inline within the body.
*/
spinIconWhenActive?: boolean;
+ /**
+ * Treat an empty result as interrupted too. For tools that create
+ * their result message before running (`exec_shell_command`), so a
+ * stop mid-run leaves an empty result rather than none.
+ */
+ emptyResultIsInterrupted?: boolean;
/**
* Wrapper component that renders the title row and the body
* children. Defaults to CollapsibleContentBlock;
@@ -65,6 +72,7 @@
let {
children,
+ emptyResultIsInterrupted = false,
extraLiveStreaming = false,
isStreaming,
meta,
@@ -81,6 +89,13 @@
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
+ // A finished call with no result was stopped before it returned (user
+ // abort, or the page went away mid-execution).
+ const isInterrupted = $derived(
+ section.type === AgenticSectionType.TOOL_CALL &&
+ (section.toolResult === undefined || (emptyResultIsInterrupted && !section.toolResult)) &&
+ !showSpinner
+ );
const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName));
const toolIcon: Component = $derived(
@@ -107,6 +122,8 @@
if (isStreamingCall && !isStreaming) return 'incomplete';
+ if (isInterrupted) return 'interrupted';
+
return undefined;
}
@@ -126,6 +143,7 @@
>
{@render children(meta, {
isCodeStreaming,
+ isInterrupted,
isPending,
isStreaming,
isStreamingCall
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
index b6138dfc0..c2a8188ec 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
@@ -26,11 +26,16 @@
let {
isLastAssistantMessage = false,
- isStreaming = false,
+ isStreaming: isChatStreaming = false,
message,
toolMessages = []
}: Props = $props();
+ // The chat-level streaming flag is shared by every message; only the last
+ // assistant message is actually live, so earlier ones must not keep their
+ // unresolved tool calls spinning while a newer reply streams.
+ const isStreaming = $derived(isChatStreaming && isLastAssistantMessage);
+
let expandedStates: Record<number, boolean> = $state({});
const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
@@ -48,9 +53,7 @@
);
const pendingPermission = $derived(
- isStreaming && isLastAssistantMessage
- ? agenticStore.getPendingPermissionRequest(message.convId)
- : null
+ isStreaming ? agenticStore.getPendingPermissionRequest(message.convId) : null
);
// dismissal applies to the request object, so the next request ( new
@@ -67,9 +70,7 @@
}
const pendingContinue = $derived(
- isStreaming && isLastAssistantMessage
- ? agenticStore.getPendingContinueRequest(message.convId)
- : false
+ isStreaming ? agenticStore.getPendingContinueRequest(message.convId) : false
);
let continueDismissed = $state(false);
diff --git a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
index 87668c0e3..e948cc922 100644
--- a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
+++ b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
@@ -81,6 +81,7 @@ describe('exec_shell_command streaming title', () => {
it('streams the partial command into the title with a cursor', async () => {
const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
@@ -98,6 +99,7 @@ describe('exec_shell_command streaming title', () => {
it('drops the cursor once the args JSON is complete', async () => {
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
@@ -108,6 +110,7 @@ describe('exec_shell_command streaming title', () => {
expect(hasCursor(screen.container)).toBe(true);
screen.rerender({
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
@@ -120,6 +123,7 @@ describe('exec_shell_command streaming title', () => {
it('switches the prompt from the conversation cwd to the persisted one', async () => {
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
@@ -131,6 +135,7 @@ describe('exec_shell_command streaming title', () => {
// Result message lands: its x-tool-cwd takes over the prompt.
screen.rerender({
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: [toolResultMessage('', '/other/dir')]
@@ -143,6 +148,7 @@ describe('exec_shell_command streaming title', () => {
it('shows the executing label only after the command finishes streaming', async () => {
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
@@ -154,6 +160,7 @@ describe('exec_shell_command streaming title', () => {
expect(screen.container.textContent).not.toContain('Executing command...');
screen.rerender({
+ isLastAssistantMessage: true,
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
@@ -192,6 +199,7 @@ describe('exec_shell_command streaming title', () => {
conversationsStore.activeMessages = [messageWithArgs('')];
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: conversationsStore.activeMessages[0]!,
toolMessages: []
@@ -214,3 +222,57 @@ describe('exec_shell_command streaming title', () => {
}
});
});
+
+describe('exec_shell_command interrupted call', () => {
+ beforeEach(() => {
+ settingsStore.updateMultipleConfig({
+ [SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: true,
+ [SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
+ });
+ });
+
+ it('marks a call without a result as interrupted once streaming stops', async () => {
+ const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
+ isStreaming: false,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(triggerText(container)).toContain('interrupted');
+ expect(container.textContent).toContain('Command interrupted');
+ expect(container.textContent).not.toContain('Executing command...');
+ });
+
+ it('marks a command stopped before any output as interrupted', async () => {
+ // exec_shell_command creates its result message before running, so a
+ // stop mid-run leaves an empty result rather than none.
+ const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
+ isStreaming: false,
+ message: messageWithArgs('{"command":"sleep 40"}'),
+ toolMessages: [toolResultMessage('', CWD)]
+ });
+
+ await tick();
+
+ expect(triggerText(container)).toContain('interrupted');
+ expect(container.textContent).toContain('Command interrupted');
+ });
+
+ it('does not keep spinning in an earlier message while a newer reply streams', async () => {
+ const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: false,
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(triggerText(container)).toContain('interrupted');
+ expect(container.textContent).not.toContain('Executing command...');
+ });
+});
diff --git a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
index e51fcb795..ffb28e369 100644
--- a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
+++ b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
@@ -95,6 +95,7 @@ describe('Show tool call in progress', () => {
setSettings({ alwaysShow: false, inProgress: true });
const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []
@@ -107,6 +108,7 @@ describe('Show tool call in progress', () => {
it('keeps the call collapsed while executing when the setting is off', async () => {
const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []
@@ -123,6 +125,7 @@ describe('Show tool call in progress', () => {
setSettings({ alwaysShow: false, inProgress: true });
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []
@@ -135,6 +138,7 @@ describe('Show tool call in progress', () => {
// Same block, now finished: it must close on its own, no toggle given.
screen.rerender({
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: [toolResultMessage()]
@@ -150,6 +154,7 @@ describe('Show tool call in progress', () => {
setSettings({ alwaysShow: false, inProgress: true });
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []
@@ -161,6 +166,7 @@ describe('Show tool call in progress', () => {
await collapseBlock(screen);
screen.rerender({
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: [toolResultMessage()]
@@ -175,6 +181,7 @@ describe('Show tool call in progress', () => {
setSettings({ alwaysShow: true, inProgress: false });
const { container } = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: [toolResultMessage()]
@@ -189,6 +196,7 @@ describe('Show tool call in progress', () => {
setSettings({ alwaysShow: false, inProgress: true });
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []
@@ -206,6 +214,7 @@ describe('Show tool call in progress', () => {
it('lets a manual expand win over the collapsed default', async () => {
const screen = render(ChatMessageAgenticContent, {
+ isLastAssistantMessage: true,
isStreaming: true,
message: assistantMessage(),
toolMessages: []