Files
llama.cpp-xrip/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
Aleksander Grygier 84746c5c94 ui: Add "Show tool call in progress" setting (#30681)
* feat(ui): add Show tool call in progress display setting

Add the showToolCallInProgress key and its checkbox to Settings > Display,
right next to its "always show" counterpart. Upstream dropped a setting of
this exact name in 43b5e63589, folding it into alwaysShowToolCallContent
because the old name promised expansion that outlives the call; this brings
back the narrower, in-progress-only meaning.

The entry is registry-driven, so SETTING_CONFIG_DEFAULT, the dialog field and
the ui_settings admin baseline pick it up without extra wiring. It ships
enabled, mirroring Show thought in progress, and the always-show help text is
reworded so the two toggles read as a pair rather than contradicting each
other.

Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)

* feat(ui): expand tool call blocks while they are in progress

Drive the default expansion of TOOL_CALL_STREAMING and TOOL_CALL_PENDING from
showToolCallInProgress and leave TOOL_CALL, the terminal state, to
alwaysShowToolCallContent. A finished call now folds back on its own once its
result lands, which is exactly how the reasoning block behaves when the
thought completes.

Manual toggling keeps precedence over both defaults in either direction:
isExpanded() only consults the settings while expandedStates holds no entry
for the section.

Client tests pin the truth table for the two toggles, including the automatic
fold-back and both manual overrides.

Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)

* fix(ui): keep running tool calls expanded

A call the agentic loop reports as running now outranks its section type.
exec_shell_command persists an empty result before reading any output, so a
live call is already TOOL_CALL and folded back, which hid the streaming
output the setting is meant to show.

Move the expansion default to isSectionExpandedByDefault so the running-id
rule is covered by a truth table, and test the pending -> finished transition
instead of only the finished state: a block that only ever got mounted
finished cannot prove it folds back on its own.

Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)

* feat(ui): stream the exec command from the first character

The exec parser read its args strictly, so while the model was still
typing the call the block title stayed empty and the body claimed
"Running...". Parse the args partially (the read_file / edit_file /
write_file pattern), keep a terminal cursor while the args JSON is
incomplete, and stand the conversation cwd in for the x-tool-cwd that
only arrives with the result message, so the prompt is there from the
first character. The body stays empty while the args stream, shows
"Executing command..." once the call is executing, and the wrapper
drops its padding for empty phases so the title sits flush with the
frame.

Assisted by: Delta (zai-org/GLM-5.3)

* fix(ui): stream file tool bodies even before a path arrives

write_file and edit_file refused to render until their args carried a
path, so a call that streams its body first (or a same-named tool with
other arg names) showed an empty block. Meta now carries optional
fileName/filePath, path alias reading moves to shared helpers, and the
blocks fall back to the raw args JSON when no known content key is
present.

Assisted by: Delta (zai-org/GLM-5.3)

* fix(ui): keep an empty write_file body instead of the raw args

A call carrying {"content":""} fell through to the raw-args fallback, so
a finished write of an empty file showed the args JSON. The parser now
leaves content undefined when the key is missing and the block tests
!== undefined, so an empty write renders as an empty code block with its
byte count.

Assisted by: DeepSeek-V4.1-Flash (deepseek-ai)
2026-10-22 08:14:47 +03:00

217 lines
6.4 KiB
TypeScript

// Guards the exec_shell_command title while the call is still being
// generated: the command must stream into the terminal title from the
// first character (args arrive as partial JSON), with a blinking cursor
// while the args are incomplete, and the conversation cwd standing in
// for the x-tool-cwd value that only arrives with the result message.
import ChatMessageToolCallBlockExecShellCommand from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte';
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
import { SETTINGS_KEYS } from '$lib/constants';
import { AgenticSectionType, BuiltInTool, MessageRole, MessageType } from '$lib/enums';
import { conversationsStore, settingsStore } from '$lib/stores';
import type { AgenticSection, DatabaseMessage } from '$lib/types';
import { tick } from 'svelte';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
const CONV_ID = 'conv-1';
const TOOL_CALL_ID = 'call-1';
const TOOL_NAME = BuiltInTool.SERVER_EXEC_SHELL_COMMAND;
const CWD = '/home/user/project';
function messageWithArgs(args: string): DatabaseMessage {
return {
children: [],
content: '',
convId: CONV_ID,
id: 'assistant-1',
parent: null,
role: MessageRole.ASSISTANT,
timestamp: 0,
toolCalls: JSON.stringify([
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
]),
type: MessageType.TEXT
};
}
function toolResultMessage(content: string, toolCwd?: string): DatabaseMessage {
return {
children: [],
content,
convId: CONV_ID,
id: 'tool-1',
parent: null,
role: MessageRole.TOOL,
timestamp: 0,
toolCallId: TOOL_CALL_ID,
toolCalls: '',
toolCwd,
type: MessageType.TEXT
};
}
function triggerText(container: Element): string {
return container.querySelector('[data-slot="collapsible-trigger"]')?.textContent ?? '';
}
function hasCursor(container: Element): boolean {
return container.querySelector('.exec-cursor') !== null;
}
describe('exec_shell_command streaming title', () => {
beforeEach(() => {
settingsStore.updateMultipleConfig({
[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: false,
[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
});
conversationsStore.activeConversation = {
currNode: null,
cwd: CWD,
id: CONV_ID,
lastModified: 0,
name: 'test'
};
});
afterEach(() => {
conversationsStore.activeConversation = null;
conversationsStore.activeMessages = [];
});
it('streams the partial command into the title with a cursor', async () => {
const { container } = render(ChatMessageAgenticContent, {
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
});
await tick();
const title = triggerText(container);
expect(title).toContain('git sta');
expect(title).toContain('$');
expect(title).toContain(CWD);
expect(hasCursor(container)).toBe(true);
});
it('drops the cursor once the args JSON is complete', async () => {
const screen = render(ChatMessageAgenticContent, {
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
});
await tick();
expect(hasCursor(screen.container)).toBe(true);
screen.rerender({
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
});
await tick();
expect(triggerText(screen.container)).toContain('git status');
expect(hasCursor(screen.container)).toBe(false);
});
it('switches the prompt from the conversation cwd to the persisted one', async () => {
const screen = render(ChatMessageAgenticContent, {
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
});
await tick();
expect(triggerText(screen.container)).toContain(CWD);
// Result message lands: its x-tool-cwd takes over the prompt.
screen.rerender({
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: [toolResultMessage('', '/other/dir')]
});
await tick();
expect(triggerText(screen.container)).toContain('/other/dir');
expect(triggerText(screen.container)).not.toContain(CWD);
});
it('shows the executing label only after the command finishes streaming', async () => {
const screen = render(ChatMessageAgenticContent, {
isStreaming: true,
message: messageWithArgs('{"command":"git sta'),
toolMessages: []
});
await tick();
// While the args stream, the title cursor alone signals activity.
expect(screen.container.textContent).not.toContain('Executing command...');
screen.rerender({
isStreaming: true,
message: messageWithArgs('{"command":"git status"}'),
toolMessages: []
});
await tick();
expect(screen.container.textContent).toContain('Executing command...');
});
it('shows Executing command... while executing before the first output chunk', async () => {
// The result message is created empty right before execution, so
// the block must not go blank while the process produces nothing.
const section: AgenticSection = {
content: '',
toolArgs: '{"command":"sleep 1"}',
toolCallId: TOOL_CALL_ID,
toolName: TOOL_NAME,
toolResult: '',
type: AgenticSectionType.TOOL_CALL
};
const { container } = render(ChatMessageToolCallBlockExecShellCommand, {
isExecuting: true,
isStreaming: true,
open: true,
section
});
await tick();
expect(container.textContent).toContain('Executing command...');
});
it('updates the title when the store mutates toolCalls in place, as the stream does', async () => {
// The live flow never swaps the message object: onToolCallsStreaming
// writes growing toolCalls strings into the same store row per delta.
conversationsStore.activeMessages = [messageWithArgs('')];
const screen = render(ChatMessageAgenticContent, {
isStreaming: true,
message: conversationsStore.activeMessages[0]!,
toolMessages: []
});
await tick();
const idx = conversationsStore.findMessageIndex('assistant-1');
for (const args of ['{"command', '{"command":"git', '{"command":"git sta']) {
conversationsStore.updateMessageAtIndex(idx, {
toolCalls: JSON.stringify([
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
])
});
await tick();
expect(triggerText(screen.container)).toContain(args.slice('{"command":"'.length));
expect(hasCursor(screen.container)).toBe(true);
}
});
});