ui: Improve Chat Messages rendering performance (#28460)
* 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
This commit is contained in:
@@ -290,3 +290,114 @@ describe('hasAgenticContent', () => {
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-section cache: completed turns are immutable, so repeated
|
||||
// derivations return the same section objects - which is what keeps tool
|
||||
// block props stable while another turn streams. Every field the cache
|
||||
// compares must invalidate it; a miss here renders stale content.
|
||||
|
||||
describe('completed turn section reuse', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
anchor: makeAssistant({
|
||||
content: 'answer',
|
||||
reasoningContent: 'thinking',
|
||||
toolCalls: toolCallsJson
|
||||
}),
|
||||
tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the same section objects for unchanged inputs', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
});
|
||||
|
||||
it('recomputes when the assistant content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.content = 'edited';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('recomputes when reasoning content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.reasoningContent = 'new thinking';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when toolCalls change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.toolCalls = '[]';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when a tool result or its extras change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].content = 'new tool result';
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
|
||||
|
||||
const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].extra = [{ type: 'image' } as never];
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
|
||||
});
|
||||
|
||||
it('never reuses the streaming turn', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps completed turns stable while the last turn streams', () => {
|
||||
const anchor = makeAssistant({
|
||||
content: 'turn one',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
|
||||
const tools = [
|
||||
makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
|
||||
continuation,
|
||||
makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
|
||||
];
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
// turn one is complete: identical section objects across derivations
|
||||
expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
|
||||
// the streaming last turn recomputed: fresh section objects
|
||||
expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Sibling-info correctness for buildSiblingInfoMap, including the memoized
|
||||
// leaf resolution. A wrong leaf id here breaks branch navigation, so the
|
||||
// deep-chain and multi-branch cases below pin the resolution down.
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
|
||||
return {
|
||||
children,
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> m1 -> ... -> m depth, each node with a single child. */
|
||||
function linearChain(depth: number): DatabaseMessage[] {
|
||||
const messages = [msg('m0', null, ['m1'])];
|
||||
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe('buildSiblingInfoMap', () => {
|
||||
it('resolves the deepest leaf for every node of a long single chain', () => {
|
||||
const messages = linearChain(50);
|
||||
const map = buildSiblingInfoMap(messages);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every non-root message of the chain is an only child, and its
|
||||
// navigation target is the chain's deepest leaf
|
||||
for (const m of messages.slice(1)) {
|
||||
const info = map.get(m.id);
|
||||
|
||||
expect(info?.totalSiblings).toBe(1);
|
||||
expect(info?.siblingIds).toEqual([leafId]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling position and leaf targets on a branched tree', () => {
|
||||
// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
|
||||
const root = msg('m0', null, ['m1', 'm4']);
|
||||
const m1 = msg('m1', 'm0', ['m2']);
|
||||
const m2 = msg('m2', 'm1', ['m3', 'm6']);
|
||||
const m3 = msg('m3', 'm2');
|
||||
const m4 = msg('m4', 'm0', ['m5']);
|
||||
const m5 = msg('m5', 'm4');
|
||||
const m6 = msg('m6', 'm2');
|
||||
const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
|
||||
|
||||
// m1 and m4 share the root as parent; their nav targets are the
|
||||
// leaves of their subtrees ( m6 for the first branch, m5 for the second )
|
||||
expect(map.get(m1.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
expect(map.get(m4.id)).toMatchObject({
|
||||
currentIndex: 1,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
|
||||
// m3 and m6 are siblings under m2; both are leaves
|
||||
expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
|
||||
expect(map.get(m6.id)?.currentIndex).toBe(1);
|
||||
|
||||
// the root has no parent and reports itself
|
||||
expect(map.get(root.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [root.id],
|
||||
totalSiblings: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with findLeafNode for arbitrary nodes', () => {
|
||||
const messages = linearChain(20);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every node of the chain resolves to the deepest leaf
|
||||
for (const m of messages) {
|
||||
expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Field updates to the active conversation must keep the object identity
|
||||
// stable: effects that track the identity ( the chat screen's sibling-info
|
||||
// refresh ) refire on every identity change, which used to trigger a full
|
||||
// message refetch on every send and tool result.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: {
|
||||
getConversation: vi.fn(),
|
||||
getConversationMessages: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
updateCurrentNode: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const getConversationMock = vi.mocked(DatabaseService.getConversation);
|
||||
const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
|
||||
const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
|
||||
|
||||
function makeConversation(overrides: Partial<DatabaseConversation> = {}): DatabaseConversation {
|
||||
return {
|
||||
currNode: 'node-1',
|
||||
id: 'conv-1',
|
||||
lastModified: 1000,
|
||||
name: 'conversation',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
|
||||
getConversationMock.mockResolvedValue(conversation);
|
||||
getMessagesMock.mockResolvedValue(messages);
|
||||
|
||||
expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getConversationMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
updateCurrentNodeMock.mockReset();
|
||||
updateCurrentNodeMock.mockResolvedValue(undefined);
|
||||
vi.mocked(DatabaseService.updateConversation).mockReset();
|
||||
vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('active conversation identity', () => {
|
||||
it('hands the load read off exactly once', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
|
||||
// a second consume is a miss: branch actions must fall back to a refetch
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes currNode in place on updateCurrentNode', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
await conversationsStore.updateCurrentNode('node-2');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
|
||||
});
|
||||
|
||||
it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.name).toBe('renamed');
|
||||
expect(conversationsStore.activeConversation?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('writes lastModified in place on updateConversationTimestamp', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.updateConversationTimestamp('conv-1');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,21 @@ describe('isExitCodeSummaryLine', () => {
|
||||
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandExitStatus tail scan', () => {
|
||||
it('finds the marker at the end of a blob larger than the tail window', () => {
|
||||
// the parser matches only the last ~128 chars; a marker past that
|
||||
// window must still parse, and an earlier fake must not match
|
||||
const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
|
||||
const status = parseExecShellCommandExitStatus(blob);
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps rejecting markers that are not at the absolute end', () => {
|
||||
const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
|
||||
|
||||
expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -119,3 +120,27 @@ describe('isWebSearchToolName', () => {
|
||||
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchResults prefilter', () => {
|
||||
it('returns the shared empty array for blobs without the wire format', () => {
|
||||
// exec/file tool results never carry Title:/URL: field lines; the
|
||||
// cheap prefilter must skip the line-split parse for them
|
||||
const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
|
||||
|
||||
expect(extractSearchResults(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty result when only one required field is present', () => {
|
||||
expect(extractSearchResults('URL: https://example.com')).toEqual([]);
|
||||
expect(extractSearchResults('Title: only a title')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeSearchResult', () => {
|
||||
it('requires both Title and URL field markers', () => {
|
||||
expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
|
||||
expect(looksLikeSearchResult('URL: https://b')).toBe(false);
|
||||
expect(looksLikeSearchResult('plain stdout')).toBe(false);
|
||||
expect(looksLikeSearchResult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,15 @@ describe('tryParseToolResultObject', () => {
|
||||
expect(tryParseToolResultObject('{bad')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseToolResultObject gating', () => {
|
||||
it('parses JSON objects that start after leading whitespace', () => {
|
||||
expect(tryParseToolResultObject('\n {"result":"ok"}')).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('skips the parse for large plain-text results', () => {
|
||||
// most tool results are file contents or stdout; the gate avoids a
|
||||
// doomed JSON.parse over the whole blob
|
||||
expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
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';
|
||||
@@ -7,10 +10,10 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
parseWriteFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, WriteFileMeta } from '$lib/types';
|
||||
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -223,6 +226,113 @@ describe('parseWriteFileMeta', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user