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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user