diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts index bf4409384..18d2edffc 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest' import { buildToolView, + clampForDisplay, countDiffLineStats, inlineDiffFromResult, + MAX_TOOL_RENDER_CHARS, type ToolPart } from './tool-fallback-model' @@ -110,6 +112,36 @@ describe('buildToolView file edit diffs', () => { }) }) +describe('clampForDisplay', () => { + it('passes short payloads through untouched', () => { + expect(clampForDisplay('hello')).toBe('hello') + expect(clampForDisplay('x'.repeat(MAX_TOOL_RENDER_CHARS))).toHaveLength(MAX_TOOL_RENDER_CHARS) + }) + + it('truncates oversized payloads and reports the omitted count', () => { + const oversized = 'x'.repeat(MAX_TOOL_RENDER_CHARS + 5_000) + const clamped = clampForDisplay(oversized) + + expect(clamped.length).toBeLessThan(oversized.length) + expect(clamped.startsWith('x'.repeat(MAX_TOOL_RENDER_CHARS))).toBe(true) + expect(clamped).toContain('5,000 more characters truncated') + expect(clamped).toContain('Copy') + }) +}) + +// A large tool result (e.g. a 100KB read_file during a `/learn` run) must not +// be serialized into the rendered rawResult at full size — that JSON.stringify +// payload is what floods the renderer when many rows stack up. +describe('buildToolView caps serialized result size', () => { + it('clamps rawResult for an oversized result', () => { + const huge = 'y'.repeat(MAX_TOOL_RENDER_CHARS * 3) + const view = buildToolView(part({ result: { content: huge }, toolName: 'read_file' }), '') + + expect(view.rawResult.length).toBeLessThanOrEqual(MAX_TOOL_RENDER_CHARS + 200) + expect(view.rawResult).toContain('truncated') + }) +}) + describe('countDiffLineStats', () => { it('counts added and removed lines', () => { expect( diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 6e67b0b9a..f74696f35 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -238,8 +238,26 @@ function contextValue(value: unknown): string { return typeof value === 'string' ? value : '' } +// Each tool result is server-capped (~100KB), but a turn over a big directory +// stacks many rows; painting/serializing them all floods the renderer (freeze, +// then OOM). Clamp every inline-painted payload to a bounded slice — the row's +// Copy button still reads the uncapped `view.detail` for the full output. +export const MAX_TOOL_RENDER_CHARS = 20_000 + +export function clampForDisplay(value: string, max = MAX_TOOL_RENDER_CHARS): string { + if (value.length <= max) { + return value + } + + const omitted = value.length - max + + return `${value.slice(0, max)}\n\n… ${omitted.toLocaleString()} more characters truncated — use Copy for the full output.` +} + function prettyJson(value: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value, null, 2) + const raw = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + + return clampForDisplay(raw ?? '') } function parseMaybeObject(value: unknown): Record { diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 5e8a1a0b1..aa2a1b5b0 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -33,6 +33,7 @@ import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/sto import { PendingToolApproval } from './tool-approval' import { buildToolView, + clampForDisplay, cleanVisibleText, countDiffLineStats, inlineDiffFromResult, @@ -104,7 +105,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string { }) .filter(Boolean) - return parts.join('\n') + return clampForDisplay(parts.join('\n')) } function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { @@ -220,13 +221,25 @@ function ToolEntry({ part }: ToolEntryProps) { const messageRunning = useAuiState(selectMessageRunning) const embedded = useContext(ToolEmbedContext) const toolViewMode = useStore($toolViewMode) - const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` + + // `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos + // below and re-running buildToolView (full JSON.stringify of result) on every + // stream delta — the freeze on big `/learn` runs. Re-derive a stable part from + // the referentially-stable args/result so the memos hold across deltas. + const { args, isError, result, toolCallId, toolName } = part + + const stablePart = useMemo( + () => ({ args, isError, result, toolCallId, toolName, type: 'tool-call' }), + [args, isError, result, toolCallId, toolName] + ) + + const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}` const dismissed = useStore($toolRowDismissed(disclosureId)) - const isPending = messageRunning && part.result === undefined + const isPending = messageRunning && result === undefined const liveDiffs = useStore($toolInlineDiffs) - const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' - const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) - const isFileEdit = isFileEditTool(part.toolName) + const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : '' + const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result) + const isFileEdit = isFileEditTool(toolName) const defaultOpen = Boolean(inlineDiff) const open = useDisclosureOpen(disclosureId, defaultOpen) const canDismiss = !isPending && !embedded @@ -237,13 +250,14 @@ function ToolEntry({ part }: ToolEntryProps) { const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) - // Stale parts (no result, but message stopped running) get a synthetic - // empty result so buildToolView treats them as completed-no-output. + // Stale parts (no result, but message stopped running) get a synthetic empty + // result so buildToolView treats them as completed-no-output. Keyed on + // stablePart so it recomputes only when this tool's data changes. const view = useMemo(() => { - const p = !isPending && part.result === undefined ? { ...part, result: {} } : part + const p = !isPending && result === undefined ? { ...stablePart, result: {} } : stablePart return buildToolView(p, inlineDiff) - }, [inlineDiff, isPending, part]) + }, [inlineDiff, isPending, result, stablePart]) // Surface a previewable artifact (HTML file / localhost URL) as a compact link // in the composer status stack rather than a bulky inline card. Uses the same @@ -313,7 +327,9 @@ function ToolEntry({ part }: ToolEntryProps) { view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical' ) - const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) + // copyAction reads the uncapped view.detail; clampForDisplay below only bounds + // what's painted, so the row's Copy button still yields the full output. + const copyAction = useMemo(() => toolCopyPayload(stablePart, view), [stablePart, view]) const diffStats = useMemo( () => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null), @@ -466,7 +482,7 @@ function ToolEntry({ part }: ToolEntryProps) { detailSections.summary && 'mt-1.5' )} > - {detailSections.body} + {clampForDisplay(detailSections.body)} )} @@ -481,7 +497,7 @@ function ToolEntry({ part }: ToolEntryProps) {
{view.stderr &&

stdout

}
-                      {view.rendersAnsi ?  : view.stdout}
+                      {view.rendersAnsi ?  : clampForDisplay(view.stdout)}
                     
)} @@ -494,7 +510,7 @@ function ToolEntry({ part }: ToolEntryProps) { 'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)' )} > - {view.rendersAnsi ? : view.stderr} + {view.rendersAnsi ? : clampForDisplay(view.stderr)} )} @@ -504,10 +520,10 @@ function ToolEntry({ part }: ToolEntryProps) { {view.detailLabel &&

{view.detailLabel}

} {renderDetailAsCode ? (
-                    {view.rendersAnsi ?  : view.detail}
+                    {view.rendersAnsi ?  : clampForDisplay(view.detail)}
                   
) : ( - + )} ))}