* 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>
502 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|