* ui : update active conversation fields in place updateCurrentNode, applyConversationUpdate, updateConversationTimestamp and the pin toggle replaced the whole activeConversation object, so its identity changed on every send, tool result and rename. ChatMessages tracks that identity to refresh sibling info, so each replacement triggered a full refetch of every message in the conversation. Write the changed fields instead, mirroring updateMessageAtIndex. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse the conversation load read for sibling info Opening a conversation read every message from the database twice: once in loadConversation for the active path, once in ChatMessages for the sibling map. Hand the freshly read array over once so the chat screen builds sibling info from it, and set the conversation and its messages in one sync block so effects never see the new conversation paired with the previous one's messages. Assisted-by: pi:zai-org/GLM-5.3 * ui : memoize leaf walks in sibling map build buildSiblingInfoMap resolves each sibling's leaf by walking the last-child chain, once per sibling per message, so the walk repeats along the same chains for every message in the conversation ( O(messages^2) on long chats ). Memoize leaf resolution per build with path compression so each edge is walked once. Assisted-by: pi:zai-org/GLM-5.3 * ui : skip sibling refetch for in-place message edits refreshAllMessages refetches every message of the conversation just to rebuild sibling info, but preserve-responses and non-branching assistant edits never create branches, so the sibling map stays valid. Refresh only after actions that branch (editWithBranching kept) or delete. Assisted-by: pi:zai-org/GLM-5.3 * ui : drop unused currentResponse reactive writes Nothing reads chatStore.currentResponse, but setChatStreaming reassigned it on every streamed chunk, so each token paid a reactive write and string assignment for nothing. Remove the field and the clearUIState wrapper that only reset it. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse completed agentic turn sections during streaming deriveAgenticSections runs in a $derived invalidated per streamed chunk, but re-derived every turn of the session each time, so per-chunk cost grew with session length. Cache completed turns keyed by their assistant message plus reference checks on every field that feeds derivation; only the streaming turn recomputes. Cache hits return the same section objects, so tool block props stay stable and skip their per-chunk re-derive. Assisted-by: pi:zai-org/GLM-5.3 * ui : share markdown block infrastructure Every markdown block duplicated shared work: a full copy of the hljs theme CSS per instance, and the remark/rehype plugin chain rebuilt on every processMarkdown call ( once per block at mount, again per coalesced chunk while streaming ). Use the single theme style element already maintained by SyntaxHighlightedCode, and build pipelines once - shared process-wide for attachment-less blocks, cached by attachments identity otherwise. Assisted-by: pi:zai-org/GLM-5.3 * ui : measure assistant layout only for the last message Every assistant message ran getComputedStyle, getBoundingClientRect and a ResizeObserver over the previous user bubble at mount, even off-screen ones, forcing a layout pass per message while a long conversation renders. The measured vars only feed the :last-child min-height rule, so gate the effect on isLastAssistantMessage; one measurement and one observer remain, and the effect re-runs when the last message changes. Assisted-by: pi:zai-org/GLM-5.3 * ui : trim whole-blob scans in tool block headers Tool block headers parsed their entire blobs at mount, even collapsed, and most tool results and args are large plain text or embedded file content: skip JSON.parse unless the blob starts with a JSON container, prefilter search-result extraction with a Title:/URL: substring check, and match the end-anchored exit-code marker against only the tail of exec outputs. Assisted-by: pi:zai-org/GLM-5.3 * ui : parse write_file and edit_file titles without the content blob Both block headers parsed the full args JSON at mount, even collapsed, and write_file and edit_file args embed the whole file content or edit strings, so every block paid a full-blob JSON parse just to read the path. Split the meta into a title tier that extracts the path with a targeted key match (full parse only as fallback) and a body tier that keeps the full parse; Svelte deriveds are lazy, and the body snippet renders only while the block is expanded, so collapsed blocks no longer parse args. Assisted-by: pi:zai-org/GLM-5.3 * ui : mount chat messages lazily near the viewport Every message row mounted its full component tree on load, so the cycle collector, GC and layout invalidation kept walking every live object and DOM node even for rows the user never scrolls to - which dominated the profile of long conversations. Wrap each row in a placeholder with an IntersectionObserver ( two viewport heights of runway ) that swaps in the real ChatMessage when the row approaches the viewport; the row shell keeps the content-visibility sizing, and rows stay mounted once realized. Rows targeted by the pending-edit flow mount eagerly. Assisted-by: pi:zai-org/GLM-5.3 * ui : smooth the chat navigation animations Slide the centered new-chat form to the bottom edge with a transform instead of a bottom offset - layout-property transitions need the main thread every frame and stutter while a long conversation loads, while transform transitions run on the compositor. Fade the message list in with a CSS animation keyed to the conversation id, disabled under prefers-reduced-motion. Assisted-by: pi:zai-org/GLM-5.3 * ui : follow the svelte runes guidance in chat message code Two effects detected changes with manual previous-value refs and reset flags. The permission request carries object identity, so its dismissal is now a derived comparing the dismissed request; the continue request is a bare boolean, so its dismissal only shrinks to a reset while no request is pending. Also drop a dead if (browser) guard in the markdown theme loader - effects never run on the server. Assisted-by: pi:zai-org/GLM-5.3 * test : pin the chat perf invariants in the unit suite Cover the fixes whose silent regression would be stale or wrong UI rather than a crash: the turn-section cache must reuse unchanged turns yet recompute on every field it compares; the sibling map must resolve the same leaves after the leaf-walk memoization; the active conversation must keep its identity through field updates; and the blob gates ( exec tail window, plain-text result gate, search prefilter ) must keep accepting what they gate. Only the risky invariants are pinned - no coverage for coverage's sake. Assisted-by: pi:zai-org/GLM-5.3 * refactor : address review remarks Name the tool-arg string-field pattern, move the file tools' path field aliases and the JSON container gates into lib/constants, and export the write_file / edit_file meta types from $lib/types instead of the parser modules. Assisted-by: pi:zai-org/GLM-5.3
651 lines
19 KiB
TypeScript
651 lines
19 KiB
TypeScript
import { 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 { 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('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 null when args have no path-like field', () => {
|
|
expect(
|
|
parseWriteFileMeta(
|
|
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
|
)
|
|
).toBeNull();
|
|
});
|
|
|
|
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 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 null when args have no path-like field', () => {
|
|
expect(
|
|
parseEditFileTitleMeta(
|
|
makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
|
|
)
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|