();
+
+function buildPipeline({
+ attachments,
+ disableMath = false
+}: MarkdownProcessorOptions): MarkdownProcessor {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
+
+ if (!disableMath) {
+ proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
+ }
+
+ proc = proc
+ .use(remarkBreaks) // Convert line breaks to
+ // Treat raw HTML as literal text with preserved indentation
+ .use(remarkLiteralHtml)
+ .use(remarkRehype); // Convert Markdown AST to rehype
+
+ if (!disableMath) {
+ proc = proc.use(rehypeKatex); // Render math using KaTeX
+ }
+
+ const pipeline = proc
+ .use(rehypeHighlight, {
+ aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
+ languages: lowlightAll
+ }) // Add syntax highlighting
+ .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g. , ) inside Markdown tables
+ .use(rehypeEnhanceLinks) // Add target="_blank" to links
+ .use(rehypeFileBadge) // Render file:// anchors as inline badge chips
+ .use(rehypeMermaidPre) // Convert mermaid blocks to
+ .use(rehypeSvgPre) // Convert svg blocks to
+ .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
+ .use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
+ .use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
+ .use(rehypeResolveAttachmentImages, { attachments })
+ .use(rehypeRtlSupport) // Add bidirectional text support
+ .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
+
+ return pipeline as MarkdownProcessor;
+}
+
+export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
+ if (options.attachments && options.attachments.length > 0) {
+ let cached = attachmentPipelines.get(options.attachments);
+
+ if (!cached) {
+ cached = buildPipeline(options);
+ attachmentPipelines.set(options.attachments, cached);
+ }
+
+ return cached;
+ }
+
+ const key = String(Boolean(options.disableMath));
+
+ let cached = sharedPipelines.get(key);
+
+ if (!cached) {
+ cached = buildPipeline(options);
+ sharedPipelines.set(key, cached);
+ }
+
+ return cached;
+}
diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts
index e3241373e..d93ae6429 100644
--- a/tools/ui/src/lib/constants/index.ts
+++ b/tools/ui/src/lib/constants/index.ts
@@ -16,6 +16,7 @@ export * from './context-gauge-popup.constants';
export * from './conversation-import.constants';
export * from './binary-detection.constants';
export * from './content-detection.constants';
+export * from './tool-call-args.constants';
export * from './tool-ui.constants';
export * from './cache.constants';
export * from './chat-form.constants';
diff --git a/tools/ui/src/lib/constants/tool-call-args.constants.ts b/tools/ui/src/lib/constants/tool-call-args.constants.ts
new file mode 100644
index 000000000..e74260be2
--- /dev/null
+++ b/tools/ui/src/lib/constants/tool-call-args.constants.ts
@@ -0,0 +1,23 @@
+// Tool-args and tool-result parsing helpers: the file tools' path field
+// aliases, the JSON container gates for result blobs, and the targeted
+// string-field pattern used for cheap title-tier extraction.
+
+/**
+ * Field aliases the file tools accept for the path argument. Tool contracts
+ * drifted over time: some models emit `file_path` / `filePath`.
+ */
+export const TOOL_ARG_PATH_KEYS: readonly string[] = ['path', 'file_path', 'filePath'];
+
+/** Opening character of a JSON object; only an object root can carry fields. */
+export const JSON_OBJECT_OPEN = '{';
+
+/** Opening character of a JSON array; successful sandbox output is one. */
+export const JSON_ARRAY_OPEN = '[';
+
+/**
+ * Matches `"": ""` in a JSON args blob ( whitespace between
+ * tokens allowed ), capturing the raw string literal so only that literal
+ * gets decoded; escaped quotes stay inside the value group. `{key}` is
+ * replaced with the field name before use.
+ */
+export const TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE = '"{key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"';
diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts
index 296c2cca5..4bdcc6845 100644
--- a/tools/ui/src/lib/stores/chat/index.svelte.ts
+++ b/tools/ui/src/lib/stores/chat/index.svelte.ts
@@ -55,7 +55,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
string,
{ response: string; messageId: string; model?: string | null }
>();
- currentResponse = $state('');
errorDialogState = $state(null);
// true while the active conversation has a local pipe (send, attach or resume-wait)
isLoading = $derived(this.activity.isLocal(conversationsStore.activeConversation?.id ?? ''));
@@ -256,8 +255,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
this.chatStreamingStates.delete(convId);
-
- if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
}
clearEditMode(): void {
this.isEditModeActive = false;
@@ -272,11 +269,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
this.pendingMessages.delete(convId);
}
- /** Reset per-view state when (re)mounting the empty chat screen. */
- clearUIState(): void {
- this.currentResponse = '';
- }
-
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
@@ -766,8 +758,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
model: model ?? this.chatStreamingStates.get(convId)?.model,
response
});
-
- if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
}
setEditModeActive(handler: (files: File[]) => void): void {
@@ -1244,7 +1234,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
syncLoadingStateForChat(convId: string): void {
const s = this.chatStreamingStates.get(convId);
- this.currentResponse = s?.response || '';
this.processing.setActiveConversation(convId);
// Sync streaming content to activeMessages so UI displays current content
diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts
index df5b1ecef..c4fea2e4e 100644
--- a/tools/ui/src/lib/stores/conversations/index.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts
@@ -52,6 +52,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
private initPromise: Promise | null = null;
+ /**
+ * Messages loadConversation just read, handed off once so the chat
+ * screen can reuse them for sibling info instead of re-fetching the
+ * whole conversation a second time.
+ */
+ private lastLoadedMessages: { convId: string; messages: DatabaseMessage[] } | null = null;
+
/**
* Memo of the last findMessageIndex() lookup. Streaming calls it once per
* chunk for the same message, so a validated cache hit keeps that O(1)
@@ -88,7 +95,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
}
if (this.activeConversation?.id === id) {
- this.activeConversation = { ...this.activeConversation, ...updates };
+ // field-wise, not object replacement: effects that track the active
+ // conversation identity would otherwise refire on every rename or pin
+ const target = this.activeConversation as unknown as Record;
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (target[key] !== value) target[key] = value;
+ }
}
}
@@ -202,11 +215,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
const activeId = this.activeConversation?.id;
- if (activeId && updates.has(activeId)) {
- this.activeConversation = {
- ...this.activeConversation!,
- pinned: updates.get(activeId)!
- };
+ if (this.activeConversation && activeId && updates.has(activeId)) {
+ this.activeConversation.pinned = updates.get(activeId)!;
}
for (let i = 0; i < this.conversations.length; i++) {
@@ -236,6 +246,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
this.preferences.resetPending();
}
+ /** One-shot handoff of the messages the last loadConversation read. */
+ consumeLastLoadedMessages(convId: string): DatabaseMessage[] | null {
+ if (this.lastLoadedMessages?.convId !== convId) return null;
+
+ const messages = this.lastLoadedMessages.messages;
+
+ this.lastLoadedMessages = null;
+
+ return messages;
+ }
+
/**
* Creates a new conversation and navigates to it
* @param name - Optional name for the conversation
@@ -509,22 +530,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
// it doesn't belong to this conversation.
this.preferences.pendingCwd = null;
+ const allMessages = await DatabaseService.getConversationMessages(convId);
+
+ // set conversation and messages in one sync block so effects never see
+ // the new conversation with the previous conversation's messages
+ this.lastLoadedMessages = { convId, messages: allMessages };
this.activeConversation = conversation;
-
- if (conversation.currNode) {
- const allMessages = await DatabaseService.getConversationMessages(convId);
- const filteredMessages = filterByLeafNodeId(
- allMessages,
- conversation.currNode,
- false
- ) as DatabaseMessage[];
-
- this.activeMessages = filteredMessages;
- } else {
- const messages = await DatabaseService.getConversationMessages(convId);
-
- this.activeMessages = messages;
- }
+ this.activeMessages = conversation.currNode
+ ? (filterByLeafNodeId(allMessages, conversation.currNode, false) as DatabaseMessage[])
+ : allMessages;
return true;
} catch (error) {
@@ -558,7 +572,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
- this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
+ this.activeConversation.currNode = currentLeafNodeId;
await this.refreshActiveMessages();
if (rootMessage && this.activeMessages.length > 0) {
@@ -694,7 +708,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
}
if (this.activeConversation?.id === targetId) {
- this.activeConversation = { ...this.activeConversation, lastModified: now };
+ this.activeConversation.lastModified = now;
}
DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
@@ -710,7 +724,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
- this.activeConversation = { ...this.activeConversation, currNode: nodeId };
+ this.activeConversation.currNode = nodeId;
}
/**
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index d91c2811a..333c1bd3c 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -209,7 +209,16 @@ export type {
export type { DesktopIconStripItem } from './navigation';
// Tools types
-export type { ToolEntry, ToolGroup, ToolUiEntry } from './tools';
+export type {
+ EditFileEdit,
+ EditFileMeta,
+ EditFileTitleMeta,
+ ToolEntry,
+ ToolGroup,
+ ToolUiEntry,
+ WriteFileMeta,
+ WriteFileTitleMeta
+} from './tools';
// Reasoning
export type { ReasoningEffortLevel } from './reasoning';
diff --git a/tools/ui/src/lib/types/tools.d.ts b/tools/ui/src/lib/types/tools.d.ts
index edcec65c7..fa8963bd1 100644
--- a/tools/ui/src/lib/types/tools.d.ts
+++ b/tools/ui/src/lib/types/tools.d.ts
@@ -31,3 +31,50 @@ export interface ToolGroup {
serverId?: string;
tools: ToolEntry[];
}
+
+export interface WriteFileMeta {
+ fileName: string;
+ filePath: string;
+ language: string;
+ content: string;
+ bytesWritten?: number;
+ resultMessage?: string;
+ errorMessage?: string;
+}
+
+/** Everything the write_file block title and status pill show; the full meta
+ * ( with the embedded file content ) stays body-only so collapsed blocks
+ * never parse the content blob. */
+export interface WriteFileTitleMeta {
+ fileName: string;
+ filePath: string;
+ language: string;
+ bytesWritten?: number;
+ resultMessage?: string;
+ errorMessage?: string;
+}
+
+export interface EditFileEdit {
+ oldText: string;
+ newText: string;
+}
+
+export interface EditFileMeta {
+ fileName: string;
+ filePath: string;
+ edits: EditFileEdit[];
+ resultMessage?: string;
+ editsApplied?: number;
+ errorMessage?: string;
+}
+
+/** Everything the edit_file block title and status pill show; the full meta
+ * ( with the embedded edit strings ) stays body-only so collapsed blocks
+ * never parse the args blob. */
+export interface EditFileTitleMeta {
+ fileName: string;
+ filePath: string;
+ resultMessage?: string;
+ editsApplied?: number;
+ errorMessage?: string;
+}
diff --git a/tools/ui/src/lib/utils/agentic.ts b/tools/ui/src/lib/utils/agentic.ts
index cd150c5ef..28b3f43ee 100644
--- a/tools/ui/src/lib/utils/agentic.ts
+++ b/tools/ui/src/lib/utils/agentic.ts
@@ -109,6 +109,89 @@ function deriveSingleTurnSections(
return sections;
}
+interface TurnSectionsCacheEntry {
+ content: string | undefined;
+ extra: DatabaseMessageExtra[] | undefined;
+ reasoningContent: string | undefined;
+ toolCalls: string | undefined;
+ toolMessageContents: (string | undefined)[];
+ toolMessageExtras: (DatabaseMessageExtra[] | undefined)[];
+ toolMessages: DatabaseMessage[];
+ sections: AgenticSection[];
+}
+
+const turnSectionsCache = new WeakMap();
+
+function isTurnCacheValid(
+ entry: TurnSectionsCacheEntry,
+ message: DatabaseMessage,
+ toolMessages: DatabaseMessage[]
+): boolean {
+ if (
+ entry.content !== message.content ||
+ entry.reasoningContent !== message.reasoningContent ||
+ entry.toolCalls !== message.toolCalls ||
+ entry.extra !== message.extra
+ ) {
+ return false;
+ }
+
+ if (entry.toolMessages.length !== toolMessages.length) return false;
+
+ for (let i = 0; i < toolMessages.length; i++) {
+ if (entry.toolMessages[i] !== toolMessages[i]) return false;
+
+ if (entry.toolMessageContents[i] !== toolMessages[i].content) return false;
+
+ if (entry.toolMessageExtras[i] !== toolMessages[i].extra) return false;
+ }
+
+ return true;
+}
+
+/**
+ * deriveSingleTurnSections with structural reuse for completed turns.
+ *
+ * deriveAgenticSections runs in a $derived invalidated per streamed chunk, but
+ * only the last turn actually changes. Messages mutate in place and are never
+ * replaced, so a WeakMap keyed by the turn's assistant message plus reference
+ * checks on every field deriveSingleTurnSections reads detects any change. A
+ * cache hit also returns the same section objects, keeping downstream props
+ * stable so tool blocks skip their per-chunk re-derive. The streaming turn
+ * recomputes uncached on every chunk.
+ */
+function deriveTurnSections(
+ message: DatabaseMessage,
+ toolMessages: DatabaseMessage[],
+ streamingToolCalls: ApiChatCompletionToolCall[],
+ isStreaming: boolean
+): AgenticSection[] {
+ if (isStreaming || streamingToolCalls.length > 0) {
+ return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
+ }
+
+ const cached = turnSectionsCache.get(message);
+
+ if (cached && isTurnCacheValid(cached, message, toolMessages)) {
+ return cached.sections;
+ }
+
+ const sections = deriveSingleTurnSections(message, toolMessages, [], false);
+
+ turnSectionsCache.set(message, {
+ content: message.content,
+ extra: message.extra,
+ reasoningContent: message.reasoningContent,
+ sections,
+ toolCalls: message.toolCalls,
+ toolMessageContents: toolMessages.map((tm) => tm.content),
+ toolMessageExtras: toolMessages.map((tm) => tm.extra),
+ toolMessages
+ });
+
+ return sections;
+}
+
/**
* Derives display sections from structured message data.
*
@@ -132,13 +215,13 @@ export function deriveAgenticSections(
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
if (!hasAssistantContinuations) {
- return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
+ return deriveTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
}
const sections: AgenticSection[] = [];
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
- sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
+ sections.push(...deriveTurnSections(message, firstTurnToolMsgs, [], false));
let i = firstTurnToolMsgs.length;
@@ -150,7 +233,7 @@ export function deriveAgenticSections(
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
sections.push(
- ...deriveSingleTurnSections(
+ ...deriveTurnSections(
msg,
turnToolMsgs,
isLastTurn ? streamingToolCalls : [],
diff --git a/tools/ui/src/lib/utils/branching.ts b/tools/ui/src/lib/utils/branching.ts
index 6c2c895cb..43d33d424 100644
--- a/tools/ui/src/lib/utils/branching.ts
+++ b/tools/ui/src/lib/utils/branching.ts
@@ -105,18 +105,34 @@ export function filterByLeafNodeId(
*/
function findLeafNodeInMap(
nodeMap: ReadonlyMap,
- messageId: string
+ messageId: string,
+ leafCache?: Map
): string {
+ const path: string[] = [];
+
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
while (currentNode && currentNode.children.length > 0) {
// Follow the last child (most recent branch)
+ const cached = leafCache?.get(currentNode.id);
+
+ if (cached !== undefined) {
+ for (const id of path) leafCache?.set(id, cached);
+
+ return cached;
+ }
+
+ path.push(currentNode.id);
const lastChildId = currentNode.children[currentNode.children.length - 1];
currentNode = nodeMap.get(lastChildId);
}
- return currentNode?.id ?? messageId;
+ const leafId = currentNode?.id ?? messageId;
+
+ for (const id of path) leafCache?.set(id, leafId);
+
+ return leafId;
}
/**
@@ -176,7 +192,8 @@ export function findDescendantMessages(
*/
export function getMessageSiblings(
nodeMap: ReadonlyMap,
- messageId: string
+ messageId: string,
+ leafCache?: Map
): ChatMessageSiblingInfo | null {
const message = nodeMap.get(messageId);
@@ -212,7 +229,7 @@ export function getMessageSiblings(
// Convert sibling message IDs to their corresponding leaf node IDs
// This allows navigation between different conversation branches
const siblingLeafIds = siblingIds.map((siblingId: string) =>
- findLeafNodeInMap(nodeMap, siblingId)
+ findLeafNodeInMap(nodeMap, siblingId, leafCache)
);
// Find current message's position among siblings
const currentIndex = siblingIds.indexOf(messageId);
@@ -236,9 +253,12 @@ export function buildSiblingInfoMap(
): Map {
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
const siblingMap = new Map();
+ // Leaf walks repeat along the same child chains for every message; memoize
+ // them per build so each edge is walked once instead of O(messages^2)
+ const leafCache = new Map();
for (const msg of messages) {
- const info = getMessageSiblings(nodeMap, msg.id);
+ const info = getMessageSiblings(nodeMap, msg.id, leafCache);
if (info) {
siblingMap.set(msg.id, info);
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 079cdc871..721618c48 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -285,7 +285,8 @@ export {
extractSearchResults,
extractSearchQuery,
faviconForUrl,
- isWebSearchToolName
+ isWebSearchToolName,
+ looksLikeSearchResult
} from './search-results';
// Cache utilities
diff --git a/tools/ui/src/lib/utils/parse-exec-shell-error.ts b/tools/ui/src/lib/utils/parse-exec-shell-error.ts
index 42d2ee254..a7b2eb5c8 100644
--- a/tools/ui/src/lib/utils/parse-exec-shell-error.ts
+++ b/tools/ui/src/lib/utils/parse-exec-shell-error.ts
@@ -3,8 +3,14 @@ export function parseExecShellCommandError(
): string | undefined {
if (!toolResultString) return undefined;
+ // Exec results are usually large plain-text stdout; only a JSON object
+ // root can carry an error field, so skip the parse otherwise
+ const trimmed = toolResultString.trimStart();
+
+ if (trimmed[0] !== '{') return undefined;
+
try {
- const parsed: unknown = JSON.parse(toolResultString);
+ const parsed: unknown = JSON.parse(trimmed);
if (
parsed &&
diff --git a/tools/ui/src/lib/utils/parse-exec-shell-status.ts b/tools/ui/src/lib/utils/parse-exec-shell-status.ts
index 1f7ec557e..71dd110bd 100644
--- a/tools/ui/src/lib/utils/parse-exec-shell-status.ts
+++ b/tools/ui/src/lib/utils/parse-exec-shell-status.ts
@@ -15,15 +15,18 @@ export interface ExecShellExitStatus {
}
// Anchor to the absolute end so intermediate "[exit code: N]" string content
-// (e.g. a shell echo) doesn't false-positive.
+// (e.g. a shell echo) doesn't false-positive. The marker is at most ~50 chars
+// with the timed-out suffix, so matching a tail slice keeps the cost constant
+// for megabyte exec outputs instead of scanning the whole blob.
const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
+const EXIT_CODE_TAIL_SCAN = 128;
export function parseExecShellCommandExitStatus(
toolResultString: string | undefined
): ExecShellExitStatus | undefined {
if (!toolResultString) return undefined;
- const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
+ const match = toolResultString.slice(-EXIT_CODE_TAIL_SCAN).match(EXIT_CODE_TAIL_REGEX);
if (!match) return undefined;
diff --git a/tools/ui/src/lib/utils/search-results.ts b/tools/ui/src/lib/utils/search-results.ts
index facf7766d..0fe861d94 100644
--- a/tools/ui/src/lib/utils/search-results.ts
+++ b/tools/ui/src/lib/utils/search-results.ts
@@ -156,6 +156,20 @@ function parseChunk(chunk: string): SearchResult | null {
return result;
}
+const EMPTY_SEARCH_RESULTS: SearchResult[] = [];
+
+/**
+ * Cheap prefilter for the wire format: a parseable result needs both a
+ * `Title:` and a `URL:` field line, so a blob missing either substring can
+ * never yield a result. Two substring scans cost far less than the
+ * line-split parse for the megabyte tool results exec and file tools emit.
+ */
+export function looksLikeSearchResult(text: string | undefined | null): boolean {
+ if (!text) return false;
+
+ return text.includes('Title:') && text.includes('URL:');
+}
+
/** Bounded cache for extractSearchResults results. */
const SEARCH_RESULTS_CACHE_MAX_SIZE = 32;
const searchResultsCache = new Map();
@@ -168,7 +182,7 @@ const searchResultsCache = new Map();
* tool result strings.
*/
export function extractSearchResults(text: string | undefined | null): SearchResult[] {
- if (!text) return [];
+ if (!text || !looksLikeSearchResult(text)) return EMPTY_SEARCH_RESULTS;
const cached = searchResultsCache.get(text);
diff --git a/tools/ui/src/lib/utils/tool-call-meta.ts b/tools/ui/src/lib/utils/tool-call-meta.ts
index b64bca786..2c035446d 100644
--- a/tools/ui/src/lib/utils/tool-call-meta.ts
+++ b/tools/ui/src/lib/utils/tool-call-meta.ts
@@ -4,6 +4,8 @@
// Each tool needs to surface fields like `error`, `result`, `bytes`,
// `edits_applied` without repeating the try/JSON.parse/object guard inline.
+import { JSON_OBJECT_OPEN } from '$lib/constants';
+
/**
* Parse a tool-result blob into a JSON object, or `null` if it isn't
* one. Returns null for:
@@ -16,8 +18,14 @@ export function tryParseToolResultObject(
): Record | null {
if (!toolResultString) return null;
+ // Tool results are usually large plain text (file contents, stdout); only
+ // a JSON object root can carry fields, so skip the parse otherwise
+ const trimmed = toolResultString.trimStart();
+
+ if (trimmed[0] !== JSON_OBJECT_OPEN) return null;
+
try {
- const parsed: unknown = JSON.parse(toolResultString);
+ const parsed: unknown = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record;
diff --git a/tools/ui/src/routes/(chat)/+page.svelte b/tools/ui/src/routes/(chat)/+page.svelte
index 08a6b11ad..53975d7b3 100644
--- a/tools/ui/src/routes/(chat)/+page.svelte
+++ b/tools/ui/src/routes/(chat)/+page.svelte
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
import { APP_NAME, URL_PARAMS } from '$lib/constants';
- import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
+ import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { onMount } from 'svelte';
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
@@ -77,7 +77,6 @@
}
conversationsStore.clearActiveConversation();
- chatStore.clearUIState();
await modelsStore.fetch();
diff --git a/tools/ui/tests/unit/agentic-sections.test.ts b/tools/ui/tests/unit/agentic-sections.test.ts
index 4096a1710..fdb3b2217 100644
--- a/tools/ui/tests/unit/agentic-sections.test.ts
+++ b/tools/ui/tests/unit/agentic-sections.test.ts
@@ -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]);
+ });
+});
diff --git a/tools/ui/tests/unit/branching.test.ts b/tools/ui/tests/unit/branching.test.ts
new file mode 100644
index 000000000..8a752ae2f
--- /dev/null
+++ b/tools/ui/tests/unit/branching.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/tools/ui/tests/unit/conversations-store.test.ts b/tools/ui/tests/unit/conversations-store.test.ts
new file mode 100644
index 000000000..e06546597
--- /dev/null
+++ b/tools/ui/tests/unit/conversations-store.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/tools/ui/tests/unit/parse-exec-shell-status.test.ts b/tools/ui/tests/unit/parse-exec-shell-status.test.ts
index ed499d078..7e22bf9ee 100644
--- a/tools/ui/tests/unit/parse-exec-shell-status.test.ts
+++ b/tools/ui/tests/unit/parse-exec-shell-status.test.ts
@@ -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();
+ });
+});
diff --git a/tools/ui/tests/unit/search-results.test.ts b/tools/ui/tests/unit/search-results.test.ts
index c168dec25..561ab935a 100644
--- a/tools/ui/tests/unit/search-results.test.ts
+++ b/tools/ui/tests/unit/search-results.test.ts
@@ -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);
+ });
+});
diff --git a/tools/ui/tests/unit/tool-call-meta.test.ts b/tools/ui/tests/unit/tool-call-meta.test.ts
index bb28e3830..f94d2279f 100644
--- a/tools/ui/tests/unit/tool-call-meta.test.ts
+++ b/tools/ui/tests/unit/tool-call-meta.test.ts
@@ -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();
+ });
+});
diff --git a/tools/ui/tests/unit/tool-calls.test.ts b/tools/ui/tests/unit/tool-calls.test.ts
index f84a2405e..a2274f9d2 100644
--- a/tools/ui/tests/unit/tool-calls.test.ts
+++ b/tools/ui/tests/unit/tool-calls.test.ts
@@ -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(