// 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); } }); });