diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte index a604a97e39..cc2b4a562b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -13,7 +13,12 @@ import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; import { BuiltInTool } from '$lib/enums'; import type { AgenticSection, DatabaseMessageExtra } from '$lib/types'; - import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils'; + import { + extractSearchQuery, + extractSearchResults, + isWebSearchToolName, + looksLikeSearchResult + } from '$lib/utils'; interface Props { section: AgenticSection; @@ -26,11 +31,16 @@ let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props(); - const searchResults = $derived(extractSearchResults(section.toolResult)); - const searchQuery = $derived(extractSearchQuery(section.toolArgs)); - const isSearchCall = $derived( - searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName)) - ); + // Runs for every tool block on mount, before the body renders: the cheap + // content prefilter and the tool-name allow-list come first so blobs from + // exec/file tools are never line-split or JSON-parsed here + const isSearchCall = $derived.by(() => { + if (looksLikeSearchResult(section.toolResult)) { + return extractSearchResults(section.toolResult).length > 0; + } + + return isWebSearchToolName(section.toolName) && extractSearchQuery(section.toolArgs).length > 0; + }); {#if isSearchCall} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts index 440a1f5d65..4d6910caa3 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts @@ -38,14 +38,21 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe // do we scan raw lines for the `Error:` prefix. let parsedObject: Record | null = null; - try { - const parsed: unknown = JSON.parse(toolResultString); + // Successful sandbox output is a JSON array, errors are objects; plain + // text (huge console logs) fails the parse below anyway, so only try + // when the blob starts with a JSON container + const trimmedResult = toolResultString.trimStart(); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - parsedObject = parsed as Record; + if (trimmedResult[0] === '{' || trimmedResult[0] === '[') { + try { + const parsed: unknown = JSON.parse(trimmedResult); + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedObject = parsed as Record; + } + } catch { + parsedObject = null; } - } catch { - parsedObject = null; } if (typeof parsedObject?.error === 'string') { diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 079cdc871c..721618c48f 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 42d2ee2541..a7b2eb5c8a 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 1f7ec557ed..71dd110bd1 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 facf7766df..0fe861d946 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 b64bca7868..75ecdccd5a 100644 --- a/tools/ui/src/lib/utils/tool-call-meta.ts +++ b/tools/ui/src/lib/utils/tool-call-meta.ts @@ -16,8 +16,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] !== '{') 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;