* feat(ui): add Show tool call in progress display setting
Add the showToolCallInProgress key and its checkbox to Settings > Display,
right next to its "always show" counterpart. Upstream dropped a setting of
this exact name in 43b5e63589, folding it into alwaysShowToolCallContent
because the old name promised expansion that outlives the call; this brings
back the narrower, in-progress-only meaning.
The entry is registry-driven, so SETTING_CONFIG_DEFAULT, the dialog field and
the ui_settings admin baseline pick it up without extra wiring. It ships
enabled, mirroring Show thought in progress, and the always-show help text is
reworded so the two toggles read as a pair rather than contradicting each
other.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* feat(ui): expand tool call blocks while they are in progress
Drive the default expansion of TOOL_CALL_STREAMING and TOOL_CALL_PENDING from
showToolCallInProgress and leave TOOL_CALL, the terminal state, to
alwaysShowToolCallContent. A finished call now folds back on its own once its
result lands, which is exactly how the reasoning block behaves when the
thought completes.
Manual toggling keeps precedence over both defaults in either direction:
isExpanded() only consults the settings while expandedStates holds no entry
for the section.
Client tests pin the truth table for the two toggles, including the automatic
fold-back and both manual overrides.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* fix(ui): keep running tool calls expanded
A call the agentic loop reports as running now outranks its section type.
exec_shell_command persists an empty result before reading any output, so a
live call is already TOOL_CALL and folded back, which hid the streaming
output the setting is meant to show.
Move the expansion default to isSectionExpandedByDefault so the running-id
rule is covered by a truth table, and test the pending -> finished transition
instead of only the finished state: a block that only ever got mounted
finished cannot prove it folds back on its own.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* feat(ui): stream the exec command from the first character
The exec parser read its args strictly, so while the model was still
typing the call the block title stayed empty and the body claimed
"Running...". Parse the args partially (the read_file / edit_file /
write_file pattern), keep a terminal cursor while the args JSON is
incomplete, and stand the conversation cwd in for the x-tool-cwd that
only arrives with the result message, so the prompt is there from the
first character. The body stays empty while the args stream, shows
"Executing command..." once the call is executing, and the wrapper
drops its padding for empty phases so the title sits flush with the
frame.
Assisted by: Delta (zai-org/GLM-5.3)
* fix(ui): stream file tool bodies even before a path arrives
write_file and edit_file refused to render until their args carried a
path, so a call that streams its body first (or a same-named tool with
other arg names) showed an empty block. Meta now carries optional
fileName/filePath, path alias reading moves to shared helpers, and the
blocks fall back to the raw args JSON when no known content key is
present.
Assisted by: Delta (zai-org/GLM-5.3)
* fix(ui): keep an empty write_file body instead of the raw args
A call carrying {"content":""} fell through to the raw-args fallback, so
a finished write of an empty file showed the args JSON. The parser now
leaves content undefined when the key is missing and the block tests
!== undefined, so an empty write renders as an empty code block with its
byte count.
Assisted by: DeepSeek-V4.1-Flash (deepseek-ai)
753 lines
22 KiB
TypeScript
753 lines
22 KiB
TypeScript
import {
|
|
extractToolArgPath,
|
|
parseToolArgs
|
|
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
|
import {
|
|
parseEditFileMeta,
|
|
parseEditFileTitleMeta
|
|
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
|
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
|
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
|
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
|
import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
|
|
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
|
import {
|
|
parseWriteFileMeta,
|
|
parseWriteFileTitleMeta
|
|
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
|
import { CODE_BLOCK } from '$lib/constants';
|
|
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
|
import type { AgenticSection, WriteFileMeta } from '$lib/types';
|
|
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function makeSection(
|
|
overrides: Partial<AgenticSection> = {},
|
|
toolName = BuiltInTool.SERVER_READ_FILE
|
|
): AgenticSection {
|
|
return {
|
|
content: '',
|
|
toolArgs: JSON.stringify({ path: '/foo.txt' }),
|
|
toolName,
|
|
toolResult: undefined,
|
|
type: AgenticSectionType.TOOL_CALL,
|
|
...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({ display: '~/b', path: '/a/b' });
|
|
});
|
|
|
|
it('parses the cleared marker', () => {
|
|
expect(parseCwdMessage('Working directory cleared')).toEqual({ display: '', path: null });
|
|
});
|
|
|
|
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.SERVER_READ_FILE,
|
|
makeSection({ toolArgs: undefined })
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when the tool name does not match', () => {
|
|
const result = parseToolArgs(
|
|
BuiltInTool.SERVER_READ_FILE,
|
|
makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.SERVER_WRITE_FILE)
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null when args are not valid final JSON (partial: false)', () => {
|
|
const result = parseToolArgs(
|
|
BuiltInTool.SERVER_READ_FILE,
|
|
makeSection({ toolArgs: '{"path": "/foo.tx' })
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns parsed args when valid final JSON', () => {
|
|
const result = parseToolArgs(
|
|
BuiltInTool.SERVER_READ_FILE,
|
|
makeSection({ toolArgs: '{"path":"/foo.txt"}' })
|
|
);
|
|
|
|
expect(result).toEqual({ path: '/foo.txt' });
|
|
});
|
|
|
|
it('accepts partial JSON when partial: true', () => {
|
|
const result = parseToolArgs(
|
|
BuiltInTool.SERVER_READ_FILE,
|
|
makeSection({ toolArgs: '{"path": "/foo.tx' }),
|
|
{ partial: true }
|
|
);
|
|
|
|
expect(result).toEqual({ path: '/foo.tx' });
|
|
});
|
|
});
|
|
|
|
describe('extractToolArgPath', () => {
|
|
it('reads every path alias', () => {
|
|
expect(extractToolArgPath({ path: '/a' })).toBe('/a');
|
|
expect(extractToolArgPath({ file_path: '/b' })).toBe('/b');
|
|
expect(extractToolArgPath({ filePath: '/c' })).toBe('/c');
|
|
});
|
|
|
|
it('returns undefined when no path alias is a usable string', () => {
|
|
expect(extractToolArgPath({ content: 'x' })).toBeUndefined();
|
|
expect(extractToolArgPath({ path: '' })).toBeUndefined();
|
|
expect(extractToolArgPath({ path: 7 })).toBeUndefined();
|
|
expect(extractToolArgPath(null)).toBeUndefined();
|
|
expect(extractToolArgPath(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('parseWriteFileMeta', () => {
|
|
it('returns null for sections with a different tool name', () => {
|
|
expect(
|
|
parseWriteFileMeta(
|
|
makeSection({
|
|
toolArgs: '{"path":"/x","content":"y"}',
|
|
toolName: BuiltInTool.SERVER_READ_FILE
|
|
})
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns content without a path when args have no path-like field', () => {
|
|
const meta = parseWriteFileMeta(
|
|
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
|
);
|
|
|
|
expect(meta?.content).toBe('x');
|
|
expect(meta?.filePath).toBeUndefined();
|
|
expect(meta?.fileName).toBeUndefined();
|
|
expect(meta?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
|
|
});
|
|
|
|
it('returns content streamed before the path has arrived', () => {
|
|
const meta = parseWriteFileMeta(
|
|
makeSection({ toolArgs: '{"content":"hello ', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
|
);
|
|
|
|
expect(meta?.content).toBe('hello ');
|
|
});
|
|
|
|
it('accepts partial args (renders incrementally as content streams in)', () => {
|
|
const meta = parseWriteFileMeta(
|
|
makeSection({ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
|
);
|
|
|
|
expect(meta?.filePath).toBe('/foo.t');
|
|
});
|
|
|
|
it('returns file path, language, content, bytes, resultMessage', () => {
|
|
const meta = parseWriteFileMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/foo.ts","content":"x"}',
|
|
toolName: BuiltInTool.SERVER_WRITE_FILE,
|
|
toolResult: '{"result":"wrote","bytes":42}'
|
|
},
|
|
BuiltInTool.SERVER_WRITE_FILE
|
|
)
|
|
);
|
|
|
|
expect(meta).toMatchObject<Partial<WriteFileMeta>>({
|
|
bytesWritten: 42,
|
|
content: 'x',
|
|
filePath: '/foo.ts',
|
|
language: expect.any(String),
|
|
resultMessage: 'wrote'
|
|
});
|
|
});
|
|
|
|
it('surfaces errorMessage from the result blob', () => {
|
|
const meta = parseWriteFileMeta(
|
|
makeSection({
|
|
toolArgs: '{"path":"/foo","content":"x"}',
|
|
toolName: BuiltInTool.SERVER_WRITE_FILE,
|
|
toolResult: '{"error":"permission denied"}'
|
|
})
|
|
);
|
|
|
|
expect(meta?.errorMessage).toBe('permission denied');
|
|
});
|
|
});
|
|
|
|
describe('parseWriteFileTitleMeta', () => {
|
|
it('matches the full meta for path, language and result fields', () => {
|
|
const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' });
|
|
const toolResult = '{"result":"wrote","bytes":42}';
|
|
const section = makeSection(
|
|
{ toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult },
|
|
BuiltInTool.SERVER_WRITE_FILE
|
|
);
|
|
const full = parseWriteFileMeta(section);
|
|
const title = parseWriteFileTitleMeta(section);
|
|
|
|
expect(title?.filePath).toBe(full?.filePath);
|
|
expect(title?.fileName).toBe(full?.fileName);
|
|
expect(title?.language).toBe(full?.language);
|
|
expect(title?.bytesWritten).toBe(full?.bytesWritten);
|
|
expect(title?.resultMessage).toBe(full?.resultMessage);
|
|
expect(title?.errorMessage).toBe(full?.errorMessage);
|
|
});
|
|
|
|
it('extracts a path with escaped characters without parsing the content blob', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}',
|
|
toolName: BuiltInTool.SERVER_WRITE_FILE
|
|
},
|
|
BuiltInTool.SERVER_WRITE_FILE
|
|
);
|
|
|
|
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts');
|
|
});
|
|
|
|
it('falls back to the full parse for args the extractor can not see', () => {
|
|
const section = makeSection(
|
|
{
|
|
// key written with an escaped unicode escape sequence in the name
|
|
toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}',
|
|
toolName: BuiltInTool.SERVER_WRITE_FILE
|
|
},
|
|
BuiltInTool.SERVER_WRITE_FILE
|
|
);
|
|
|
|
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts');
|
|
});
|
|
|
|
it('accepts partial args like the full parser', () => {
|
|
const section = makeSection(
|
|
{ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE },
|
|
BuiltInTool.SERVER_WRITE_FILE
|
|
);
|
|
|
|
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
|
|
});
|
|
|
|
it('returns meta without a path when args have no path-like field', () => {
|
|
const title = parseWriteFileTitleMeta(
|
|
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
|
);
|
|
|
|
expect(title?.filePath).toBeUndefined();
|
|
expect(title?.fileName).toBeUndefined();
|
|
expect(title?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
|
|
});
|
|
|
|
it('returns null for sections with a different tool name', () => {
|
|
expect(
|
|
parseWriteFileTitleMeta(
|
|
makeSection({
|
|
toolArgs: '{"path":"/x","content":"y"}',
|
|
toolName: BuiltInTool.SERVER_READ_FILE
|
|
})
|
|
)
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseEditFileTitleMeta', () => {
|
|
it('matches the full meta for path and result fields', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0),
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
|
toolResult: '{"result":"ok","edits_applied":1}'
|
|
},
|
|
BuiltInTool.SERVER_EDIT_FILE
|
|
);
|
|
const full = parseEditFileMeta(section);
|
|
const title = parseEditFileTitleMeta(section);
|
|
|
|
expect(title?.filePath).toBe(full?.filePath);
|
|
expect(title?.fileName).toBe(full?.fileName);
|
|
expect(title?.editsApplied).toBe(full?.editsApplied);
|
|
expect(title?.resultMessage).toBe(full?.resultMessage);
|
|
expect(title?.errorMessage).toBe(full?.errorMessage);
|
|
});
|
|
|
|
it('surfaces errorMessage from the result blob without parsing args', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs: '{"path":"/foo.ts","edits":[]}',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
|
toolResult: '{"error":"permission denied"}'
|
|
},
|
|
BuiltInTool.SERVER_EDIT_FILE
|
|
);
|
|
|
|
expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
|
|
});
|
|
|
|
it('returns meta without a path when args have no path-like field', () => {
|
|
const title = parseEditFileTitleMeta(
|
|
makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
|
|
);
|
|
|
|
expect(title?.filePath).toBeUndefined();
|
|
expect(title?.fileName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('parseEditFileMeta', () => {
|
|
it('parses edits array and applies editsApplied from the result', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs:
|
|
'{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
|
toolResult: '{"result":"ok","edits_applied":2}'
|
|
},
|
|
BuiltInTool.SERVER_EDIT_FILE
|
|
);
|
|
const meta = parseEditFileMeta(section);
|
|
|
|
expect(meta?.edits).toEqual([
|
|
{ newText: 'b', oldText: 'a' },
|
|
{ newText: 'd', oldText: 'c' }
|
|
]);
|
|
expect(meta?.editsApplied).toBe(2);
|
|
expect(meta?.resultMessage).toBe('ok');
|
|
});
|
|
|
|
it('drops edits with empty old_text', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE
|
|
},
|
|
BuiltInTool.SERVER_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([{ newText: '', oldText: 'a' }]);
|
|
});
|
|
|
|
it('errorMessage wins over result message', () => {
|
|
const section = makeSection(
|
|
{
|
|
toolArgs: '{"path":"/foo"}',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
|
toolResult: '{"error":"bad path","result":"ok"}'
|
|
},
|
|
BuiltInTool.SERVER_EDIT_FILE
|
|
);
|
|
const meta = parseEditFileMeta(section);
|
|
|
|
expect(meta?.errorMessage).toBe('bad path');
|
|
expect(meta?.resultMessage).toBeUndefined();
|
|
});
|
|
|
|
it('still parses edits when args have no path-like field', () => {
|
|
const meta = parseEditFileMeta(
|
|
makeSection({
|
|
toolArgs: '{"edits":[{"old_text":"a","new_text":"b"}]}',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE
|
|
})
|
|
);
|
|
|
|
expect(meta?.edits).toEqual([{ newText: 'b', oldText: 'a' }]);
|
|
expect(meta?.filePath).toBeUndefined();
|
|
});
|
|
|
|
it('parses edits streamed before the path has arrived', () => {
|
|
const meta = parseEditFileMeta(
|
|
makeSection({
|
|
// second edit is still mid-stream: only its old_text has landed
|
|
toolArgs: '{"edits":[{"old_text":"a","new_text":"b"},{"old_text":"c',
|
|
toolName: BuiltInTool.SERVER_EDIT_FILE
|
|
})
|
|
);
|
|
|
|
expect(meta?.edits).toEqual([
|
|
{ newText: 'b', oldText: 'a' },
|
|
{ newText: '', oldText: 'c' }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('parseReadFileMeta', () => {
|
|
it('parses file name alone (no range)', () => {
|
|
const meta = parseReadFileMeta(
|
|
makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.SERVER_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.SERVER_READ_FILE
|
|
)
|
|
);
|
|
|
|
expect(meta?.lineRange).toEqual({ end: 20, start: 10 });
|
|
});
|
|
|
|
it('parses start_line + line_count into a range', () => {
|
|
const meta = parseReadFileMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"path":"/foo.ts","start_line":10,"line_count":5}' },
|
|
BuiltInTool.SERVER_READ_FILE
|
|
)
|
|
);
|
|
|
|
expect(meta?.lineRange).toEqual({ end: 14, start: 10 });
|
|
});
|
|
|
|
it('returns null when args cannot be parsed', () => {
|
|
expect(
|
|
parseReadFileMeta(makeSection({ toolArgs: '{bad' }, BuiltInTool.SERVER_READ_FILE))
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseGrepSearchMeta', () => {
|
|
it('returns null when path or pattern is missing', () => {
|
|
expect(
|
|
parseGrepSearchMeta(
|
|
makeSection({ toolArgs: '{"pattern":"foo"}', toolName: BuiltInTool.SERVER_GREP_SEARCH })
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
parseGrepSearchMeta(
|
|
makeSection({ toolArgs: '{"path":"/x"}', toolName: BuiltInTool.SERVER_GREP_SEARCH })
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('parses structured plain_text_response into matches', () => {
|
|
const meta = parseGrepSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x","pattern":"foo"}',
|
|
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
|
toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
|
|
},
|
|
BuiltInTool.SERVER_GREP_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.matches).toHaveLength(2);
|
|
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts' });
|
|
});
|
|
|
|
it('falls back to raw-text parsing when result is not JSON', () => {
|
|
const meta = parseGrepSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x","pattern":"foo"}',
|
|
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
|
toolResult: 'a.ts:hello\nb.ts:world'
|
|
},
|
|
BuiltInTool.SERVER_GREP_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.matches).toHaveLength(2);
|
|
});
|
|
|
|
it('parses line numbers when return_line_numbers is true', () => {
|
|
const meta = parseGrepSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
|
|
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
|
toolResult: 'a.ts:12:hello'
|
|
},
|
|
BuiltInTool.SERVER_GREP_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts', line: 12 });
|
|
expect(meta?.showLineNumbers).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parseFileGlobSearchMeta', () => {
|
|
it('falls back to raw-text parsing when result is not JSON', () => {
|
|
const meta = parseFileGlobSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x"}',
|
|
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
|
toolResult: 'a.ts\nb.ts'
|
|
},
|
|
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
|
});
|
|
|
|
it('parses plain_text_response from a JSON object', () => {
|
|
const meta = parseFileGlobSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x"}',
|
|
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
|
toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
|
|
},
|
|
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
|
});
|
|
|
|
it('surfaces errorMessage from the result blob', () => {
|
|
const meta = parseFileGlobSearchMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"path":"/x"}',
|
|
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
|
toolResult: JSON.stringify({ error: 'permission denied' })
|
|
},
|
|
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
|
)
|
|
);
|
|
|
|
expect(meta?.errorMessage).toBe('permission denied');
|
|
});
|
|
});
|
|
|
|
describe('parseRunJavascriptMeta', () => {
|
|
it('returns null when code is missing', () => {
|
|
expect(
|
|
parseRunJavascriptMeta(
|
|
makeSection({ toolArgs: '{}', toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT })
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('reads code and timeout', () => {
|
|
const meta = parseRunJavascriptMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"code":"Math.PI","timeout_ms":5000}',
|
|
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
|
},
|
|
BuiltInTool.BROWSER_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(
|
|
{
|
|
toolArgs: '{"code":"throw new Error()"}',
|
|
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
|
toolResult: JSON.stringify({ error: 'undefined is not a function' })
|
|
},
|
|
BuiltInTool.BROWSER_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(
|
|
{
|
|
toolArgs: '{"code":"[1,2,3]"}',
|
|
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
|
toolResult: '[1,2,3]'
|
|
},
|
|
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
|
)
|
|
);
|
|
|
|
expect(meta?.errorMessage).toBeUndefined();
|
|
});
|
|
|
|
it('scans a non-JSON string result for an `Error:` line', () => {
|
|
const meta = parseRunJavascriptMeta(
|
|
makeSection(
|
|
{
|
|
toolArgs: '{"code":"foo"}',
|
|
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
|
toolResult: 'Error: undefined is not a function\n at <anonymous>:1:1'
|
|
},
|
|
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
|
)
|
|
);
|
|
|
|
expect(meta?.errorMessage).toBe('undefined is not a function');
|
|
});
|
|
});
|
|
|
|
describe('parseExecShellCommandMeta', () => {
|
|
it('reads command from the args', () => {
|
|
const meta = parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"command":"ls -la"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
);
|
|
|
|
expect(meta?.command).toBe('ls -la');
|
|
});
|
|
|
|
it('accepts cmd / shell_command aliases', () => {
|
|
expect(
|
|
parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"cmd":"ls"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
)?.command
|
|
).toBe('ls');
|
|
expect(
|
|
parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"shell_command":"ls"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
)?.command
|
|
).toBe('ls');
|
|
});
|
|
|
|
it('returns null when no command alias is present', () => {
|
|
expect(
|
|
parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"cwd":"/x"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('surfaces a partially streamed command before the JSON closes', () => {
|
|
const meta = parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"command":"git sta', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
);
|
|
|
|
expect(meta?.command).toBe('git sta');
|
|
});
|
|
|
|
it('keeps a dangling escape from breaking the partial parse', () => {
|
|
const meta = parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"command":"echo \\"he', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
);
|
|
|
|
expect(meta?.command).toBe('echo "he');
|
|
});
|
|
|
|
it('returns null while the command value is still empty', () => {
|
|
expect(
|
|
parseExecShellCommandMeta(
|
|
makeSection(
|
|
{ toolArgs: '{"command":"', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
|
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
|
)
|
|
)
|
|
).toBeNull();
|
|
});
|
|
});
|