ui: Agentic Content UX improvements (#25450)

* feat: Add shimmer text animation for processing state indicators

* feat: Redesign CollapsibleContentBlock component with improved UX

* feat: Add conditional setting display support with dependsOn field

* feat: Add showAgenticTurnStats setting for per-turn statistics

* feat: Update ChatMessageAgenticContent with improved UI and new features

* feat: Enhance file read tool UI/UX

* feat: Refine styling of collapsible content and code preview blocks

* feat: add terminal variant to CollapsibleContentBlock

* feat: add built-in tools UI registry

* feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock

* refactor: simplify ChatMessageAgenticContent to use extracted blocks

* fix: correct markdown content block margin spacing

* fix: reorganize SettingsChatFields layout and reset button positioning

* fix: use direct map access in agentic store session methods

* refactor: remove reasoning preview/throttle system from CollapsibleContentBlock

* feat: add auto-scroll to reasoning block and remove showThoughtInProgress

* feat: add ChatMessageToolCallDateTime component and support for new tool types

* feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver

* feat: show MCP server favicon for tools without a built-in icon

* feat: add search-results parsing utilities and tests

* feat: add ChatMessageToolCallSearchResults component

* feat: integrate search results rendering into ChatMessageAgenticContent

* feat: display tool call input alongside output in ChatMessageToolCallBlock

* style: use muted foreground color in reasoning block content

* chore: Format

* feat: Refine reasoning block layout and make pending thoughts display configurable

* feat: Stream tool call code blocks with auto-scroll and handle partial JSON

* feat: add streaming permission gate infrastructure

* feat: wire permission gate into the agentic loop

* fix: bail out on abort and skip already-approved tool calls

* fix: clear partial tool calls on abort and savePartialResponse

* test: cover partial tool call cleanup end-to-end

* refactor: Remove streaming permission gate logic

* fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks

* refactor: Chat Message Assistant componentization

* fix: Show health metadata for disabled MCP servers and promote connections on enable

* fix: Inherit global enabled state for missing MCP per-chat overrides

* refactor: Cleanup

* refactor: Split ChatMessageToolCallBlock into dedicated components

* feat: Add live streaming and auto-scroll for tool execution output

* feat: Add line numbers and change markers to file edit diffs

* chore: Formatting

* feat: Add type definitions and utilities for recommended MCP servers

* feat: Add recommended MCP servers configuration and storage key

* feat: Add McpServerCardCompact component for recommended servers

* feat: Add recommended servers section to Add New Server dialog

* feat: Update McpServerForm to support authorization requirements

* feat: Add select-none classes for text selection prevention

* feat: Add recommended MCP server icon assets

* refactor: Store dismissed MCP recommendations as a boolean flag

* feat: Render tool results as JSON or Markdown based on detected content type

* feat: UI improvement

* feat: Render search block early and update heading to show execution state

* fix: Prevent non-web-search tools from triggering the search UI block

* refactor: Cleanup

* refactor: Extract hardcoded icon size classes into shared constants

* refactor: Extract hardcoded tool result separator into a shared constant

* refactor: Tool Calls UI/logic

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup
This commit is contained in:
Aleksander Grygier
2026-07-15 20:31:45 +02:00
committed by GitHub
parent 3b53219361
commit 32beb244f5
146 changed files with 5960 additions and 1053 deletions
@@ -95,6 +95,29 @@ describe('deriveAgenticSections', () => {
expect(sections[0].toolName).toBe('bash');
});
it('chat-streaming write_file surfaces as TOOL_CALL_PENDING with partial toolArgs (not TOOL_CALL_STREAMING)', () => {
// Regression: while the LLM is emitting a write_file tool call's
// args, `chat.svelte.ts` JSON-encodes the partial tool-call array on
// every chunk, so `parseToolCalls` succeeds and the section is
// classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is
// only produced from the `streamingToolCalls` parameter, never set
// by current UI callers). Streaming-only UI like auto-scroll in the
// code block must still trigger, driven by `isStreaming && (isPending
// || isStreamingCall)`, not `isStreamingCall` alone.
const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
const msg = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'write_file', arguments: partialArgs } }
])
});
const sections = deriveAgenticSections(msg, [], [], true);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
expect(sections[0].toolName).toBe('write_file');
expect(sections[0].toolArgs).toBe(partialArgs);
});
it('multi-turn: two assistant turns grouped as one session', () => {
const assistant1 = makeAssistant({
id: 'ast-1',
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants';
import { buildAssistantRawOutput, type AgenticSection } from '$lib/utils/agentic';
function makeSection(
overrides: Partial<AgenticSection> & { type: AgenticSectionType }
): AgenticSection {
return {
content: '',
...overrides
};
}
describe('buildAssistantRawOutput', () => {
it('returns empty string for empty sections', () => {
expect(buildAssistantRawOutput([])).toBe('');
});
it('formats a reasoning section with a single newline between tags and content', () => {
const sections = [makeSection({ type: AgenticSectionType.REASONING, content: 'thinking...' })];
expect(buildAssistantRawOutput(sections)).toBe(
`${REASONING_TAGS.START}\nthinking...${REASONING_TAGS.END}`
);
});
it('formats a text section as-is', () => {
const sections = [makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' })];
expect(buildAssistantRawOutput(sections)).toBe('Hello');
});
it('formats a tool call with JSON args and no result label', () => {
const sections = [
makeSection({
type: AgenticSectionType.TOOL_CALL,
toolName: 'read_file',
toolArgs: JSON.stringify({ path: '/tmp/file.txt' }),
toolResult: 'file contents'
})
];
expect(buildAssistantRawOutput(sections)).toBe(
[
'{',
' "name": "read_file",',
' "arguments": {',
' "path": "/tmp/file.txt"',
' }',
'}',
'',
'',
'file contents'
].join('\n')
);
});
it('joins multiple sections with double newlines', () => {
const sections = [
makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' }),
makeSection({ type: AgenticSectionType.TOOL_CALL, toolName: 'noop' })
];
expect(buildAssistantRawOutput(sections)).toBe('Hello\n\n{\n "name": "noop"\n}');
});
it('falls back to raw string args when JSON parsing fails', () => {
const sections = [
makeSection({
type: AgenticSectionType.TOOL_CALL,
toolName: 'broken',
toolArgs: '{not json',
toolResult: 'result'
})
];
expect(buildAssistantRawOutput(sections)).toBe(
['{', ' "name": "broken",', ' "arguments": "{not json"', '}', '', '', 'result'].join('\n')
);
});
});
@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { classifyToolResult } from '$lib/utils/agentic';
describe('classifyToolResult', () => {
describe('text', () => {
it('returns text for undefined input', () => {
expect(classifyToolResult(undefined)).toBe('text');
});
it('returns text for empty string', () => {
expect(classifyToolResult('')).toBe('text');
});
it('returns text for whitespace-only input', () => {
expect(classifyToolResult(' \n ')).toBe('text');
});
it('returns text for plain prose', () => {
expect(classifyToolResult('Hello, this is just some text.')).toBe('text');
});
it('returns text for shell-style line listings', () => {
expect(classifyToolResult('file1.java\nfile2.java\nfile3.java\n')).toBe('text');
});
it('returns text when a brace-like string is not valid JSON', () => {
expect(classifyToolResult('{key: value}')).toBe('text');
});
});
describe('json', () => {
it('classifies a flat JSON object', () => {
expect(classifyToolResult('{"key": "value", "n": 42}')).toBe('json');
});
it('classifies a JSON array', () => {
expect(classifyToolResult('["a", "b", "c"]')).toBe('json');
});
it('classifies a pretty-printed JSON object', () => {
expect(classifyToolResult('{\n "key": "value"\n}')).toBe('json');
});
it('classifies a deeply nested JSON payload', () => {
const nested = JSON.stringify({ items: [{ id: 1, tags: ['a', 'b'] }] }, null, 2);
expect(classifyToolResult(nested)).toBe('json');
});
it('prefers JSON over inner markdown markers when the content starts with a brace', () => {
// A JSON object whose inner strings contain link syntax still
// reads as JSON because the leading `{` parses cleanly -
// `classifyToolResult` only inspects the top-level shape, not
// every nested line marker.
const jsonWithLink = '{"docs": "see [docs](https://example.com) for more"}';
expect(classifyToolResult(jsonWithLink)).toBe('json');
});
});
describe('markdown', () => {
it('classifies an ATX header line', () => {
expect(classifyToolResult('# Title\n\nSome text below.')).toBe('markdown');
});
it('classifies a fenced code block', () => {
expect(classifyToolResult('```json\n{"key": "v"}\n```')).toBe('markdown');
});
it('classifies a tilde-fenced code block', () => {
expect(classifyToolResult('~~~bash\nls -la\n~~~')).toBe('markdown');
});
it('classifies a markdown link', () => {
expect(classifyToolResult('See [docs](https://example.com) for more.')).toBe('markdown');
});
it('classifies bold text', () => {
expect(classifyToolResult('This is **very important**.')).toBe('markdown');
});
it('classifies a bulleted list', () => {
expect(classifyToolResult('- item one\n- item two\n- item three')).toBe('markdown');
});
it('classifies an ordered list', () => {
expect(classifyToolResult('1. first step\n2. second step\n3. third step')).toBe('markdown');
});
it('classifies a blockquote', () => {
expect(classifyToolResult('> quoted text\n> second line')).toBe('markdown');
});
it('classifies a markdown table', () => {
const table = '| a | b |\n| - | - |\n| 1 | 2 |';
expect(classifyToolResult(table)).toBe('markdown');
});
it('classifies a markdown table with alignment markers', () => {
const table = '| left | center | right |\n| :--- | :---: | ---: |\n| a | b | c |';
expect(classifyToolResult(table)).toBe('markdown');
});
it('classifies nested markdown headings', () => {
expect(classifyToolResult('## Section\n\n### Subsection\n')).toBe('markdown');
});
it('classifies combined markdown markers in one document', () => {
const md = [
'# Heading',
'',
'A paragraph with a [link](https://example.com) and **bold text**.',
'',
'- bullet item',
'- another bullet',
'',
'| col1 | col2 |',
'| ----- | ----- |',
'| a | b |'
].join('\n');
expect(classifyToolResult(md)).toBe('markdown');
});
});
describe('precedence', () => {
it('prefers JSON over markdown when both signals are present', () => {
// Starts with `[`, parses as JSON - markdown check is skipped.
const arr = '[1, 2, "# not-a-heading", "**not-bold**"]';
expect(classifyToolResult(arr)).toBe('json');
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { highlightCode, trimCodePadding } from '$lib/utils/code';
describe('trimCodePadding', () => {
it('removes a single leading newline', () => {
expect(trimCodePadding('\nfunction foo() {}')).toBe('function foo() {}');
});
it('removes multiple leading newlines', () => {
expect(trimCodePadding('\n\n\nfunction foo() {}')).toBe('function foo() {}');
});
it('removes whitespace-only leading lines', () => {
expect(trimCodePadding('\n \n\t\nfunction foo() {}')).toBe('function foo() {}');
});
it('removes a single trailing newline', () => {
expect(trimCodePadding('function foo() {}\n')).toBe('function foo() {}');
});
it('removes multiple trailing newlines', () => {
expect(trimCodePadding('function foo() {}\n\n\n')).toBe('function foo() {}');
});
it('removes whitespace-only trailing lines', () => {
expect(trimCodePadding('function foo() {}\n \n\t\n')).toBe('function foo() {}');
});
it('removes newlines on both sides at once', () => {
expect(trimCodePadding('\nfunction foo() {}\n')).toBe('function foo() {}');
});
it('preserves internal blank lines', () => {
expect(trimCodePadding('\nfunction foo() {\n\n return 1;\n}\n')).toBe(
'function foo() {\n\n return 1;\n}'
);
});
it('drops a leading whitespace-only line but keeps following code intact', () => {
expect(trimCodePadding(' \nfunction foo() {}')).toBe('function foo() {}');
});
it('passes through already-trimmed input unchanged', () => {
expect(trimCodePadding('function foo() {}')).toBe('function foo() {}');
expect(trimCodePadding('function foo() {\n return 1;\n}')).toBe(
'function foo() {\n return 1;\n}'
);
});
it('returns empty string when input is whitespace only', () => {
expect(trimCodePadding('\n\n\n')).toBe('');
expect(trimCodePadding('\n \n\t\n')).toBe('');
});
});
describe('highlightCode', () => {
it('returns empty string for empty input', () => {
expect(highlightCode('', 'javascript')).toBe('');
});
it('does not produce a leading newline in the highlighted html', () => {
const html = highlightCode('\nfunction multiply(a, b) {\n return a * b;\n}\n', 'javascript');
expect(html.startsWith('\n')).toBe(false);
expect(html.startsWith(' ')).toBe(false);
});
it('does not produce a trailing newline in the highlighted html', () => {
const html = highlightCode('\nfunction foo() {}\n', 'javascript');
expect(html.endsWith('\n')).toBe(false);
});
it('preserves internal blank lines in highlighted code', () => {
const html = highlightCode('\nfunction foo() {\n\n return 1;\n}\n', 'javascript');
expect(html).toContain('\n\n');
});
it('produces the same body for framed and unframed input', () => {
const trimmed = highlightCode('function foo() {}', 'javascript');
const framed = highlightCode('\nfunction foo() {}\n', 'javascript');
expect(framed).toBe(trimmed);
});
});
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { DiffLineKind } from '$lib/enums';
import { computeLineDiff, renderUnifiedDiff, type DiffLine } from '$lib/utils';
describe('computeLineDiff', () => {
it('returns empty for two empty inputs', () => {
expect(computeLineDiff('', '')).toEqual([]);
});
it('marks every line as removed for an empty new text', () => {
expect(computeLineDiff('a\nb\nc', '')).toEqual([
{ kind: 'remove', text: 'a', oldLine: 1 },
{ kind: 'remove', text: 'b', oldLine: 2 },
{ kind: 'remove', text: 'c', oldLine: 3 }
]);
});
it('marks every line as added for an empty old text', () => {
expect(computeLineDiff('', 'a\nb')).toEqual([
{ kind: 'add', text: 'a', newLine: 1 },
{ kind: 'add', text: 'b', newLine: 2 }
]);
});
it('detects a single-line replace', () => {
expect(computeLineDiff('old', 'new')).toEqual([
{ kind: 'add', text: 'new', newLine: 1 },
{ kind: 'remove', text: 'old', oldLine: 1 }
]);
});
it('preserves interleaved context around additions', () => {
const oldText = ['a', 'b', 'c'].join('\n');
const newText = ['a', 'b', 'B', 'c'].join('\n');
expect(computeLineDiff(oldText, newText)).toEqual([
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
{ kind: 'add', text: 'B', newLine: 3 },
{ kind: 'context', text: 'c', oldLine: 3, newLine: 4 }
]);
});
it('preserves interleaved context around an isolated replace', () => {
// Multi-line context around a one-line change -> the diff should
// show context flanking the changed line at its natural position.
const oldText = ['a', 'b', 'c', 'd'].join('\n');
const newText = ['a', 'b', 'X', 'd'].join('\n');
expect(computeLineDiff(oldText, newText)).toEqual([
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
{ kind: 'add', text: 'X', newLine: 3 },
{ kind: 'remove', text: 'c', oldLine: 3 },
{ kind: 'context', text: 'd', oldLine: 4, newLine: 4 }
]);
});
it('preserves interleaved context around removals', () => {
const oldText = ['a', 'b', 'c', 'd'].join('\n');
const newText = ['a', 'c', 'd'].join('\n');
expect(computeLineDiff(oldText, newText)).toEqual([
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
{ kind: 'remove', text: 'b', oldLine: 2 },
{ kind: 'context', text: 'c', oldLine: 3, newLine: 2 },
{ kind: 'context', text: 'd', oldLine: 4, newLine: 3 }
]);
});
it('handles purely identical inputs', () => {
const text = 'x\ny\nz';
const result = computeLineDiff(text, text);
expect(result).toEqual([
{ kind: 'context', text: 'x', oldLine: 1, newLine: 1 },
{ kind: 'context', text: 'y', oldLine: 2, newLine: 2 },
{ kind: 'context', text: 'z', oldLine: 3, newLine: 3 }
]);
});
it('strips a trailing newline on the old/new inputs', () => {
expect(computeLineDiff('a\n', 'a\nb\n')).toEqual([
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
{ kind: 'add', text: 'b', newLine: 2 }
]);
});
it('normalizes trailing CR on each line', () => {
expect(computeLineDiff('a\r\nb\r\n', 'a\nb')).toEqual([
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 }
]);
});
it('keeps line numbers monotonic across mixed add/remove/context', () => {
const oldText = ['l1', 'l2', 'l3', 'l4', 'l5'].join('\n');
const newText = ['l1', 'l2-EDIT', 'l3', 'l4-NEW', 'l5'].join('\n');
const diff = computeLineDiff(oldText, newText);
// Walk the diff: every oldLine must increase strictly, and every
// newLine must increase strictly. Lines missing one side (add or
// remove) carry no number on that side.
let lastOld = 0;
let lastNew = 0;
for (const line of diff) {
if (line.oldLine !== undefined) {
expect(line.oldLine).toBeGreaterThan(lastOld);
lastOld = line.oldLine;
}
if (line.newLine !== undefined) {
expect(line.newLine).toBeGreaterThan(lastNew);
lastNew = line.newLine;
}
}
});
});
describe('renderUnifiedDiff', () => {
it('returns empty string for empty diff', () => {
expect(renderUnifiedDiff([])).toBe('');
});
it('prefixes each line with `+`, `-`, or a single space', () => {
const lines: DiffLine[] = [
{ kind: DiffLineKind.CONTEXT, text: 'ctx' },
{ kind: DiffLineKind.ADD, text: 'plus' },
{ kind: DiffLineKind.REMOVE, text: 'minus' }
];
expect(renderUnifiedDiff(lines)).toBe(' ctx\n+plus\n-minus');
});
it('ignores oldLine/newLine metadata when emitting prefixes', () => {
const lines: DiffLine[] = [
{ kind: DiffLineKind.CONTEXT, text: 'a', oldLine: 1, newLine: 1 },
{ kind: DiffLineKind.ADD, text: 'b', newLine: 2 }
];
expect(renderUnifiedDiff(lines)).toBe(' a\n+b');
});
});
@@ -0,0 +1,143 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import type { DatabaseConversation } from '$lib/types/database';
// node env unit project has no DOM, install a minimal localStorage backed by a Map
beforeAll(() => {
const store = new Map<string, string>();
const polyfill: Storage = {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
removeItem: (k) => {
store.delete(k);
},
setItem: (k, v) => {
store.set(k, String(v));
}
};
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
});
/**
* Regression coverage for the bug where MCP servers flipped to "disabled"
* after sending the first message on a fresh chat (see comment in
* `MCPStore.createConversation`: empty `mcpServerOverrides` should inherit
* `mcpServers[i].enabled`, not be treated as all-off).
*/
describe('conversationsStore MCP override resolution', () => {
beforeEach(async () => {
localStorage.clear();
// Two configured servers: alpha is globally disabled, bravo enabled.
localStorage.setItem(
CONFIG_LOCALSTORAGE_KEY,
JSON.stringify({
[SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
{ id: 'alpha', enabled: false, url: 'https://alpha.example.com/mcp' },
{ id: 'bravo', enabled: true, url: 'https://bravo.example.com/mcp' }
])
})
);
// The settings store constructor bails in node env (no `browser`),
// so seed the config directly. The shape mirrors what `loadConfig`
// would build from localStorage.
const { settingsStore } = await import('$lib/stores/settings.svelte');
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
const saved = JSON.parse(raw) as Record<string, unknown>;
settingsStore.config = {
...settingsStore.config,
[SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
};
});
afterEach(() => {
localStorage.clear();
});
function makeConversation(
overrides?: { serverId: string; enabled: boolean }[]
): DatabaseConversation {
return {
id: 'conv-1',
currNode: null,
lastModified: 0,
name: 'Test chat',
mcpServerOverrides: overrides
};
}
it('inherits server.enabled when no conversation is active', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = null;
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('inherits server.enabled on a newly created chat with no overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = makeConversation();
// Empty override list: must fall back to global server.enabled, not all-off.
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = makeConversation(undefined);
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
// Override flips bravo off for this chat, alpha keeps its global default.
conversationsStore.activeConversation = makeConversation([
{ serverId: 'bravo', enabled: false }
]);
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(false);
});
it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = makeConversation([
{ serverId: 'alpha', enabled: true }
]);
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
{ serverId: 'alpha', enabled: true },
{ serverId: 'bravo', enabled: true }
]);
});
it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = makeConversation();
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
{ serverId: 'alpha', enabled: false },
{ serverId: 'bravo', enabled: true }
]);
});
it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
conversationsStore.activeConversation = makeConversation([
{ serverId: 'alpha', enabled: true }
]);
expect(conversationsStore.getMcpServerOverride('bravo')).toEqual({
serverId: 'bravo',
enabled: true
});
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { isExitCodeSummaryLine, parseExecShellCommandExitStatus } from '$lib/utils';
describe('parseExecShellCommandExitStatus', () => {
it('returns undefined when result is empty', () => {
expect(parseExecShellCommandExitStatus(undefined)).toBeUndefined();
expect(parseExecShellCommandExitStatus('')).toBeUndefined();
});
it('parses a zero-exit summary at end of clean stdout', () => {
const status = parseExecShellCommandExitStatus('hello world\n[exit code: 0]');
expect(status).toEqual({
code: 0,
timedOut: false,
rawText: '[exit code: 0]'
});
});
it('parses a non-zero exit summary', () => {
const status = parseExecShellCommandExitStatus('cargo: error[E0425]\n[exit code: 101]');
expect(status?.code).toBe(101);
expect(status?.timedOut).toBe(false);
});
it('detects timed-out suffix', () => {
const status = parseExecShellCommandExitStatus(
'still building...\n[exit code: -1] [exit due to timed out]'
);
expect(status?.code).toBe(-1);
expect(status?.timedOut).toBe(true);
});
it('tolerates trailing whitespace after the tail line', () => {
const status = parseExecShellCommandExitStatus('[exit code: 0] \n\n');
expect(status?.code).toBe(0);
});
it('does not match an explanatory mention of "[exit code:" not at end', () => {
// Any non-trailing occurrence should NOT trigger the badge - we
// anchor to the absolute end of the string.
const status = parseExecShellCommandExitStatus(
'the shell prints [exit code: 0]\nwhen done\nreally done\n'
);
expect(status).toBeUndefined();
});
it('does not match mid-stream exit lines followed by more output', () => {
const status = parseExecShellCommandExitStatus('[exit code: 0]\nmore output keeps streaming');
expect(status).toBeUndefined();
});
});
describe('isExitCodeSummaryLine', () => {
const status = parseExecShellCommandExitStatus('hello\n[exit code: 7]');
it('matches when line trims to the tail text', () => {
expect(isExitCodeSummaryLine(' [exit code: 7] ', status)).toBe(true);
});
it('does not match unrelated lines', () => {
expect(isExitCodeSummaryLine('plain output line', status)).toBe(false);
});
it('returns false for missing status argument', () => {
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { MessageRole } from '$lib/enums';
import { deriveAgenticSections } from '$lib/utils/agentic';
import type { DatabaseMessage } from '$lib/types/database';
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
id: overrides.id ?? 'ast-1',
convId: 'conv-1',
type: 'text',
timestamp: Date.now(),
role: MessageRole.ASSISTANT,
content: overrides.content ?? '',
parent: null,
children: [],
...overrides
} as DatabaseMessage;
}
// Mirrors the filter inside ChatService.convertDbMessageToApiChatMessageData:
// a partial tool call captured mid-stream must not survive into the next request
// payload. The fix in chatStore.savePartialResponseIfNeeded clears toolCalls to ''
// on Stop/Send immediately, mirroring what the agentic flow already does in
// onAssistantTurnComplete(...undefined).
function buildApiToolCalls(message: DatabaseMessage): unknown[] | undefined {
if (!message.toolCalls) return undefined;
try {
const parsed = JSON.parse(message.toolCalls);
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
} catch {
return undefined;
}
}
describe('partial tool call cleanup', () => {
// Reproduces the broken payload from the user's screenshot: model was
// streaming a tool call whose arguments JSON was cut mid-string. The outer
// envelope still parses, but the arguments themselves are invalid JSON and
// the server rejects the request.
it('marks a partial tool call payload as unsafe to re-send', () => {
const message = makeAssistant({
content: 'partial reasoning',
toolCalls: JSON.stringify([
{
id: 'call_1',
type: 'function',
function: {
name: 'exec_shell_command',
arguments: '{"command":`grep -n \\"read_to\\" ` /Users'
}
}
])
});
const apiToolCalls = buildApiToolCalls(message);
// The bug: even though arguments are invalid, the outer array parses and
// the request gets sent. Function arguments must be parseable JSON on their
// own for the server to execute the tool.
expect(apiToolCalls).toBeDefined();
const args = (apiToolCalls![0] as { function: { arguments: string } }).function.arguments;
expect(() => JSON.parse(args)).toThrow();
});
// After Stop, savePartialResponseIfNeeded clears toolCalls and the agentic
// flow does the same in its silent-return detection. The next request reads
// toolCalls = '' and the conversion drops the field entirely so the server
// never sees the half-streamed call.
it('drops tool_calls from the API request after toolCalls is cleared', () => {
const clearedMessage = makeAssistant({
content: 'partial reasoning',
toolCalls: ''
});
const apiToolCalls = buildApiToolCalls(clearedMessage);
expect(apiToolCalls).toBeUndefined();
});
// The cleanup path keeps the partial reasoning content visible in the UI;
// only the tool_calls field is reset. deriveAgenticSections should still
// surface the reasoning as interrupted (no content / no tool calls behind
// it) without resurrecting the dead tool call block.
it('keeps reasoning content visible after cleanup, without a tool call block', () => {
const cleared = makeAssistant({
content: '',
reasoningContent: 'thinking about read_to',
toolCalls: ''
});
const sections = deriveAgenticSections(cleared);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe('reasoning');
expect(sections.some((s) => s.type.includes('tool_call'))).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
const SAMPLE = `Title: World Cup 2026 | Match schedule, fixtures, results & stadiums
URL: https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums
Published: 2026-06-22T00:01:00.000Z
Author: N/A
Highlights:
Find out the full match schedule for World Cup 2026 in Canada, Mexico and USA with fixtures and results from each of the 104 games in the ...
---
Title: 2026 FIFA World Cup match schedule: Fixtures, results, features - ESPN
URL: https://www.espn.com/soccer/story/_/id/48939282/2026-fifa-world-cup-fixtures-results-match-schedule-group-stage-knockout-rounds-bracket
Published: 2026-07-08T07:07:00.000Z
Author: ESPN
Highlights:
Round of 32 · Tuesday, July 7 · Argentina 3-2 Egypt (Atlanta) Switzerland (4) 0-0 (3) Colombia (Vancouver, Canada) · Monday, July 6 · Portugal 0-1 ...
---
Title: BBC
URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
Published: N/A
Author: N/A
Highlights:
Something
# World Cup
...`;
const QUERY_ARGS = '{"query":"FIFA World Cup 2026 schedule"}';
describe('real-world Exa fixture', () => {
it('extracts every search result and preserves rich highlights', () => {
const results = extractSearchResults(SAMPLE);
expect(results.length).toBe(3);
expect(results[0].title).toContain('World Cup 2026 | Match schedule');
expect(results[0].url).toContain('fifa.com');
expect(results[1].title).toContain('2026 FIFA World Cup match schedule');
expect(results[1].author).toBe('ESPN');
expect(results[1].highlights).toContain('Round of 32');
expect(results[2].title).toBe('BBC');
});
it('parses the query out of the tool-args JSON', () => {
expect(extractSearchQuery(QUERY_ARGS)).toBe('FIFA World Cup 2026 schedule');
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
extractSearchResults,
extractSearchQuery,
faviconForUrl,
isWebSearchToolName
} from '$lib/utils/search-results';
describe('extractSearchResults', () => {
it('parses the Exa fixture with multiple results', () => {
const fixture = `Title: World Cup 2026 | Match schedule, fixtures
URL: https://www.fifa.com/articles/match-schedule
Published: 2026-06-22T00:01:00.000Z
Author: N/A
Highlights:
Find out the full match schedule for World Cup 2026
---
Title: 2026 FIFA World Cup match schedule
URL: https://www.espn.com/soccer/story/abc/def
Published: 2026-07-08T07:07:00.000Z
Author: ESPN
Highlights:
Round of 32 · Tuesday, July 7
---
Title: BBC
URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
Published: N/A
Author: N/A
Highlights:
# FIFA World Cup Schedule
...`;
const results = extractSearchResults(fixture);
expect(results.length).toBe(3);
expect(results[0].title).toContain('World Cup 2026');
expect(results[0].url).toBe('https://www.fifa.com/articles/match-schedule');
expect(results[0].published).toBe('2026-06-22T00:01:00.000Z');
// N/A filtered out
expect(results[0].author).toBeUndefined();
expect(results[0].highlights).toContain('match schedule');
expect(results[1].author).toBe('ESPN');
expect(results[2].author).toBeUndefined(); // N/A filtered
});
it('returns empty array for empty input', () => {
expect(extractSearchResults('')).toEqual([]);
expect(extractSearchResults(undefined)).toEqual([]);
expect(extractSearchResults(null)).toEqual([]);
});
it('skips chunks missing title or url', () => {
const txt = `Title: no url here
Highlights:
foo
---
Title: foo
URL: https://x.com
---
just a paragraph
---
Title: b
URL: not a url`;
const results = extractSearchResults(txt);
// Only middle one should pass (has title + url).
expect(results.length).toBe(1);
expect(results[0].url).toBe('https://x.com');
});
it('parses a single result without separators', () => {
const txt = `Title: only one
URL: https://example.com/test
Published: 2026-01-01T00:00:00Z
Author: alice
Highlights:
a highlight`;
const results = extractSearchResults(txt);
expect(results.length).toBe(1);
expect(results[0].title).toBe('only one');
expect(results[0].author).toBe('alice');
expect(results[0].highlights).toBe('a highlight');
});
it('extracts query from JSON toolArgs', () => {
expect(extractSearchQuery('{"query":"foo"}')).toBe('foo');
expect(extractSearchQuery(' {"query":" foo "} ')).toBe('foo');
expect(extractSearchQuery('not json')).toBe('');
expect(extractSearchQuery(null)).toBe('');
expect(extractSearchQuery('{"query":123}')).toBe('');
});
it('resolves favicon URLs from origins', () => {
expect(faviconForUrl('https://example.com/path/to/page')).toBe(
'https://example.com/favicon.ico'
);
expect(faviconForUrl('http://example.com/x')).toBe('http://example.com/favicon.ico');
expect(faviconForUrl('not a url')).toBeNull();
});
});
describe('isWebSearchToolName', () => {
it('excludes tools that take the same query argument but are not web searches', () => {
expect(isWebSearchToolName('search_pull_requests')).toBe(false);
expect(isWebSearchToolName('search_code')).toBe(false);
expect(isWebSearchToolName('search_repositories')).toBe(false);
expect(isWebSearchToolName('search_issues')).toBe(false);
});
it('handles empty / missing input', () => {
expect(isWebSearchToolName(null)).toBe(false);
expect(isWebSearchToolName(undefined)).toBe(false);
expect(isWebSearchToolName('')).toBe(false);
});
it('returns false for unrelated tools', () => {
expect(isWebSearchToolName('web_fetch')).toBe(false);
expect(isWebSearchToolName('read_file')).toBe(false);
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { parseSseJsonStream } from '$lib/utils/sse';
function makeSseResponse(events: string[]): Response {
const body = events.join('\n\n') + '\n\n';
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' }
});
}
describe('parseSseJsonStream', () => {
it('yields parsed data for each record', async () => {
const response = makeSseResponse(['data: {"chunk": "a"}', 'data: {"chunk": "b"}']);
const collected: unknown[] = [];
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
expect(collected).toEqual([{ chunk: 'a' }, { chunk: 'b' }]);
});
it('stops on [DONE] sentinel', async () => {
const response = makeSseResponse([
'data: {"chunk": "a"}',
'data: [DONE]',
'data: {"chunk": "after-done"}'
]);
const collected: unknown[] = [];
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
expect(collected).toEqual([{ chunk: 'a' }]);
});
it('skips malformed JSON records', async () => {
const response = makeSseResponse([
'data: {"chunk": "ok"}',
'data: {not-json}',
'data: {"chunk": "also-ok"}'
]);
const collected: unknown[] = [];
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
expect(collected).toEqual([{ chunk: 'ok' }, { chunk: 'also-ok' }]);
});
it('handles records split across multiple chunks (partial last line)', async () => {
const full = 'data: {"chunk": "x"}\n\ndata: {"chunk": "y"}\n\n';
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const enc = new TextEncoder();
controller.enqueue(enc.encode(full.slice(0, full.length / 2)));
controller.enqueue(enc.encode(full.slice(full.length / 2)));
controller.close();
}
});
const response = new Response(stream, {
status: 200,
headers: { 'content-type': 'text/event-stream' }
});
const collected: unknown[] = [];
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
expect(collected).toEqual([{ chunk: 'x' }, { chunk: 'y' }]);
});
it('returns immediately if response has no body', async () => {
const response = new Response(null, { status: 200 });
const collected: unknown[] = [];
for await (const ev of parseSseJsonStream(response)) {
collected.push(ev.data);
}
expect(collected).toEqual([]);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { tryParseToolResultObject } from '$lib/utils';
describe('tryParseToolResultObject', () => {
it('returns null when no result is provided', () => {
expect(tryParseToolResultObject(undefined)).toBeNull();
expect(tryParseToolResultObject('')).toBeNull();
});
it('returns the parsed object when the result is JSON', () => {
expect(tryParseToolResultObject('{"result":"ok","bytes":42}')).toEqual({
result: 'ok',
bytes: 42
});
});
it('returns null for JSON arrays (only objects are useful to callers)', () => {
expect(tryParseToolResultObject('[1,2,3]')).toBeNull();
});
it('returns null for JSON primitives', () => {
expect(tryParseToolResultObject('"raw string"')).toBeNull();
expect(tryParseToolResultObject('42')).toBeNull();
});
it('returns null for invalid JSON', () => {
expect(tryParseToolResultObject('not json')).toBeNull();
expect(tryParseToolResultObject('{bad')).toBeNull();
});
});
+416
View File
@@ -0,0 +1,416 @@
import { describe, expect, it } from 'vitest';
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/utils';
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
import {
parseWriteFileMeta,
type WriteFileMeta
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
function makeSection(
overrides: Partial<AgenticSection> = {},
toolName = BuiltInTool.READ_FILE
): AgenticSection {
return {
type: AgenticSectionType.TOOL_CALL,
content: '',
toolName,
toolArgs: JSON.stringify({ path: '/foo.txt' }),
toolResult: undefined,
...overrides
};
}
describe('parseToolArgs (shared)', () => {
it('returns null when the section has no toolArgs', () => {
const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
expect(result).toBeNull();
});
it('returns null when the tool name does not match', () => {
const result = parseToolArgs(
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.WRITE_FILE)
);
expect(result).toBeNull();
});
it('returns null when args are not valid final JSON (partial: false)', () => {
const result = parseToolArgs(
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path": "/foo.tx' })
);
expect(result).toBeNull();
});
it('returns parsed args when valid final JSON', () => {
const result = parseToolArgs(
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path":"/foo.txt"}' })
);
expect(result).toEqual({ path: '/foo.txt' });
});
it('accepts partial JSON when partial: true', () => {
const result = parseToolArgs(
BuiltInTool.READ_FILE,
makeSection({ toolArgs: '{"path": "/foo.tx' }),
{ partial: true }
);
expect(result).toEqual({ path: '/foo.tx' });
});
});
describe('parseWriteFileMeta', () => {
it('returns null for sections with a different tool name', () => {
expect(
parseWriteFileMeta(
makeSection({ toolName: BuiltInTool.READ_FILE, toolArgs: '{"path":"/x","content":"y"}' })
)
).toBeNull();
});
it('returns null when args have no path-like field', () => {
expect(
parseWriteFileMeta(
makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"content":"x"}' })
)
).toBeNull();
});
it('accepts partial args (renders incrementally as content streams in)', () => {
const meta = parseWriteFileMeta(
makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"path":"/foo.t' })
);
expect(meta?.filePath).toBe('/foo.t');
});
it('returns file path, language, content, bytes, resultMessage', () => {
const meta = parseWriteFileMeta(
makeSection(
{
toolName: BuiltInTool.WRITE_FILE,
toolArgs: '{"path":"/foo.ts","content":"x"}',
toolResult: '{"result":"wrote","bytes":42}'
},
BuiltInTool.WRITE_FILE
)
);
expect(meta).toMatchObject<Partial<WriteFileMeta>>({
filePath: '/foo.ts',
language: expect.any(String),
content: 'x',
bytesWritten: 42,
resultMessage: 'wrote'
});
});
it('surfaces errorMessage from the result blob', () => {
const meta = parseWriteFileMeta(
makeSection({
toolName: BuiltInTool.WRITE_FILE,
toolArgs: '{"path":"/foo","content":"x"}',
toolResult: '{"error":"permission denied"}'
})
);
expect(meta?.errorMessage).toBe('permission denied');
});
});
describe('parseEditFileMeta', () => {
it('parses edits array and applies editsApplied from the result', () => {
const section = makeSection(
{
toolName: BuiltInTool.EDIT_FILE,
toolArgs:
'{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
toolResult: '{"result":"ok","edits_applied":2}'
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
expect(meta?.edits).toEqual([
{ oldText: 'a', newText: 'b' },
{ oldText: 'c', newText: 'd' }
]);
expect(meta?.editsApplied).toBe(2);
expect(meta?.resultMessage).toBe('ok');
});
it('drops edits with empty old_text', () => {
const section = makeSection(
{
toolName: BuiltInTool.EDIT_FILE,
toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}'
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
// First entry is dropped (empty old_text). Second is kept
// (empty new_text is fine - it's the "delete" case).
expect(meta?.edits).toEqual([{ oldText: 'a', newText: '' }]);
});
it('errorMessage wins over result message', () => {
const section = makeSection(
{
toolName: BuiltInTool.EDIT_FILE,
toolArgs: '{"path":"/foo"}',
toolResult: '{"error":"bad path","result":"ok"}'
},
BuiltInTool.EDIT_FILE
);
const meta = parseEditFileMeta(section);
expect(meta?.errorMessage).toBe('bad path');
expect(meta?.resultMessage).toBeUndefined();
});
});
describe('parseReadFileMeta', () => {
it('parses file name alone (no range)', () => {
const meta = parseReadFileMeta(
makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.READ_FILE)
);
expect(meta?.fileName).toBe('foo.txt');
expect(meta?.lineRange).toBeNull();
});
it('parses start_line + end_line into a range', () => {
const meta = parseReadFileMeta(
makeSection(
{ toolArgs: '{"path":"/foo.ts","start_line":10,"end_line":20}' },
BuiltInTool.READ_FILE
)
);
expect(meta?.lineRange).toEqual({ start: 10, end: 20 });
});
it('parses start_line + line_count into a range', () => {
const meta = parseReadFileMeta(
makeSection(
{ toolArgs: '{"path":"/foo.ts","start_line":10,"line_count":5}' },
BuiltInTool.READ_FILE
)
);
expect(meta?.lineRange).toEqual({ start: 10, end: 14 });
});
it('returns null when args cannot be parsed', () => {
expect(parseReadFileMeta(makeSection({ toolArgs: '{bad' }, BuiltInTool.READ_FILE))).toBeNull();
});
});
describe('parseGrepSearchMeta', () => {
it('returns null when path or pattern is missing', () => {
expect(
parseGrepSearchMeta(
makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"pattern":"foo"}' })
)
).toBeNull();
expect(
parseGrepSearchMeta(
makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"path":"/x"}' })
)
).toBeNull();
});
it('parses structured plain_text_response into matches', () => {
const meta = parseGrepSearchMeta(
makeSection(
{
toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo"}',
toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
},
BuiltInTool.GREP_SEARCH
)
);
expect(meta?.matches).toHaveLength(2);
expect(meta?.matches[0]).toEqual({ file: 'a.ts', content: 'hello' });
});
it('falls back to raw-text parsing when result is not JSON', () => {
const meta = parseGrepSearchMeta(
makeSection(
{
toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo"}',
toolResult: 'a.ts:hello\nb.ts:world'
},
BuiltInTool.GREP_SEARCH
)
);
expect(meta?.matches).toHaveLength(2);
});
it('parses line numbers when return_line_numbers is true', () => {
const meta = parseGrepSearchMeta(
makeSection(
{
toolName: BuiltInTool.GREP_SEARCH,
toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
toolResult: 'a.ts:12:hello'
},
BuiltInTool.GREP_SEARCH
)
);
expect(meta?.matches[0]).toEqual({ file: 'a.ts', line: 12, content: 'hello' });
expect(meta?.showLineNumbers).toBe(true);
});
});
describe('parseFileGlobSearchMeta', () => {
it('falls back to raw-text parsing when result is not JSON', () => {
const meta = parseFileGlobSearchMeta(
makeSection(
{
toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
toolResult: 'a.ts\nb.ts'
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
});
it('parses plain_text_response from a JSON object', () => {
const meta = parseFileGlobSearchMeta(
makeSection(
{
toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
});
it('surfaces errorMessage from the result blob', () => {
const meta = parseFileGlobSearchMeta(
makeSection(
{
toolName: BuiltInTool.FILE_GLOB_SEARCH,
toolArgs: '{"path":"/x"}',
toolResult: JSON.stringify({ error: 'permission denied' })
},
BuiltInTool.FILE_GLOB_SEARCH
)
);
expect(meta?.errorMessage).toBe('permission denied');
});
});
describe('parseRunJavascriptMeta', () => {
it('returns null when code is missing', () => {
expect(
parseRunJavascriptMeta(makeSection({ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{}' }))
).toBeNull();
});
it('reads code and timeout', () => {
const meta = parseRunJavascriptMeta(
makeSection(
{ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{"code":"Math.PI","timeout_ms":5000}' },
BuiltInTool.RUN_JAVASCRIPT
)
);
expect(meta?.code).toBe('Math.PI');
expect(meta?.timeoutMs).toBe(5000);
});
it('reads error field from a JSON-object result', () => {
const meta = parseRunJavascriptMeta(
makeSection(
{
toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"throw new Error()"}',
toolResult: JSON.stringify({ error: 'undefined is not a function' })
},
BuiltInTool.RUN_JAVASCRIPT
)
);
expect(meta?.errorMessage).toBe('undefined is not a function');
});
it('does NOT treat a JSON-array result as an error', () => {
// SandboxService returns successful output as a JSON array;
// only JSON objects carry `error`. Raw arrays must round-trip
// through unchanged.
const meta = parseRunJavascriptMeta(
makeSection(
{
toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"[1,2,3]"}',
toolResult: '[1,2,3]'
},
BuiltInTool.RUN_JAVASCRIPT
)
);
expect(meta?.errorMessage).toBeUndefined();
});
it('scans a non-JSON string result for an `Error:` line', () => {
const meta = parseRunJavascriptMeta(
makeSection(
{
toolName: BuiltInTool.RUN_JAVASCRIPT,
toolArgs: '{"code":"foo"}',
toolResult: 'Error: undefined is not a function\n at <anonymous>:1:1'
},
BuiltInTool.RUN_JAVASCRIPT
)
);
expect(meta?.errorMessage).toBe('undefined is not a function');
});
});
describe('parseExecShellCommandMeta', () => {
it('reads command from the args', () => {
const meta = parseExecShellCommandMeta(
makeSection(
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"command":"ls -la"}' },
BuiltInTool.EXEC_SHELL_COMMAND
)
);
expect(meta?.command).toBe('ls -la');
});
it('accepts cmd / shell_command aliases', () => {
expect(
parseExecShellCommandMeta(
makeSection(
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cmd":"ls"}' },
BuiltInTool.EXEC_SHELL_COMMAND
)
)?.command
).toBe('ls');
expect(
parseExecShellCommandMeta(
makeSection(
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"shell_command":"ls"}' },
BuiltInTool.EXEC_SHELL_COMMAND
)
)?.command
).toBe('ls');
});
it('returns null when no command alias is present', () => {
expect(
parseExecShellCommandMeta(
makeSection(
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cwd":"/x"}' },
BuiltInTool.EXEC_SHELL_COMMAND
)
)
).toBeNull();
});
});