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:
Aleksander Grygier
2026-09-06 10:52:40 +02:00
committed by GitHub
parent 7620399f58
commit 0afb805b19
37 changed files with 1260 additions and 290 deletions
@@ -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]);
});
});
+95
View File
@@ -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();
});
});
+26 -1
View File
@@ -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();
});
});
+113 -3
View File
@@ -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(