Files
llama.cpp/tools/ui/tests/unit/tool-calls.test.ts
T
Aleksander GrygierandPascal 2f56fc3431 ui: CWD for agent (#26518)
* server : extend file_glob_search for UI pickers

* ui : add per-conversation working directory with picker

* ui : add path navigation and search scope to cwd picker

Treat path-like queries (starting with / or ~) as directory navigation
instead of glob-matching the whole query: search the parent for the last
segment, and descend into an exactly-typed directory by listing its
children. Show the effective search scope in the footer and auto-search
on open so the current directory and its siblings appear immediately.

Assisted-by: Claude

* db : persist per-call tool cwd on tool result messages

* ui : abbreviate tool paths under home with a tilde

* ui : show the per-call cwd on exec shell rows

* ui : clarify the synthetic cwd message for the model

* ui : reuse the trailing cwd row on a repeated pick

* ui : don't jump when a cwd row is injected mid-chat

* chore: Formatting

* refactor: Cleanup comments

* ui : unify working directory naming and add a synthetic-message flag

* ui : render synthetic cwd rows without a scroll jump

* ui : decouple the working directory picker into utils and sub-components

* ui : add get_info tool call block

* chore: Formatting

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: UI

* server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base)

* ui : use persisted isSynthetic flag for cwd rows, drop legacy formats

* ui : cache picker search, fail visibly on native resolve

* ui : escape glob metacharacters in picker search glob

* ui : simplify auto-scroll pin

* chore: Format

* fix: Use `SvelteMap`

* refactor: Post-review fixes

* ui: accept Windows roots in the working directory picker

recognize a drive root (C:) and a UNC share (//host/share) as path
navigation, alongside the POSIX root and ~, so a query like D:\repos
lists that directory instead of glob-matching it under the home dir

split below the root, so a bare drive resolves to its root rather than
to a drive-relative prefix

rewrite backslashes into forward slashes only when the query carries a
Windows root, since a backslash is a legal POSIX filename character

paths keep travelling with forward slashes, which is what the server
returns and what Windows accepts

---------

Co-authored-by: Pascal <admin@serveurperso.com>
2026-08-04 19:05:48 +02:00

502 lines
15 KiB
TypeScript

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 { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
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('lastPathSegment', () => {
it('returns the last segment of an absolute path', () => {
expect(lastPathSegment('/Users/me/code/my-project')).toBe('my-project');
});
it('returns the last segment of a tilde-relative path', () => {
expect(lastPathSegment('~/git/llama.brand')).toBe('llama.brand');
});
it('strips trailing slashes', () => {
expect(lastPathSegment('/foo/bar/')).toBe('bar');
});
it('strips multiple trailing slashes', () => {
expect(lastPathSegment('/foo/bar///')).toBe('bar');
});
it('returns the input unchanged when there is no slash', () => {
expect(lastPathSegment('project')).toBe('project');
});
it('returns tilde when only tilde is given', () => {
expect(lastPathSegment('~/')).toBe('~');
});
});
describe('abbreviateHome', () => {
it('abbreviates paths under home with a tilde', () => {
expect(abbreviateHome('/Users/al/Documents/x.txt', '/Users/al')).toBe('~/Documents/x.txt');
});
it('abbreviates home itself to a bare tilde', () => {
expect(abbreviateHome('/Users/al', '/Users/al')).toBe('~');
});
it('returns paths outside home unchanged', () => {
expect(abbreviateHome('/opt/project', '/Users/al')).toBe('/opt/project');
});
it('does not abbreviate a mere prefix match', () => {
expect(abbreviateHome('/Users/alice/x', '/Users/al')).toBe('/Users/alice/x');
});
it('returns the path unchanged when home is unknown', () => {
expect(abbreviateHome('/Users/al/Documents', null)).toBe('/Users/al/Documents');
});
});
describe('formatCwdMessage / parseCwdMessage', () => {
it('formats a cwd change matching the UI text, with a file link', () => {
expect(formatCwdMessage('/Users/al/Documents', '/Users/al')).toBe(
'Set working directory to [file:///Users/al/Documents](~/Documents).'
);
});
it('falls back to the basename display when home is unknown', () => {
expect(formatCwdMessage('/opt/project', null)).toBe(
'Set working directory to [file:///opt/project](project).'
);
});
it('round-trips through the parser', () => {
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
expect(info?.path).toBe('/Users/al/Documents');
expect(info?.display).toBe('~/Documents');
});
it('parses a cwd message even when guidance follows the link', () => {
expect(
parseCwdMessage(
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
)
).toEqual({ path: '/a/b', display: '~/b' });
});
it('parses the cleared marker', () => {
expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
});
it('returns null for non-cwd content', () => {
expect(parseCwdMessage('hello there')).toBeNull();
});
});
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();
});
});