diff --git a/apps/desktop/src/app/chat/transcript-window.ts b/apps/desktop/src/app/chat/transcript-window.ts index aa8848a5d2c5d..2baeb0d49ddd5 100644 --- a/apps/desktop/src/app/chat/transcript-window.ts +++ b/apps/desktop/src/app/chat/transcript-window.ts @@ -1,5 +1,5 @@ import type { ChatMessage } from '@/lib/chat-messages' -import { messageRenderWeight } from '@/lib/render-weight' +import { messageStoreWeight } from '@/lib/render-weight' /** * Bound what reaches assistant-ui at all. @@ -88,7 +88,7 @@ export function selectTranscriptWindow(messages: readonly ChatMessage[], pages = let weight = 0 for (let i = messages.length - 1; i >= 0; i--) { - weight += messageRenderWeight(messages[i].parts) + weight += messageStoreWeight(messages[i].parts) start = i if (weight >= budget && messages.length - i >= TRANSCRIPT_WINDOW_MIN_MESSAGES) { diff --git a/apps/desktop/src/lib/render-weight.test.ts b/apps/desktop/src/lib/render-weight.test.ts new file mode 100644 index 0000000000000..096c7212a3d83 --- /dev/null +++ b/apps/desktop/src/lib/render-weight.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import { messagePaintWeight, messageStoreWeight, RENDER_WEIGHT_CHARS } from './render-weight' + +const bigResult = (chars: number) => ({ + type: 'tool-call', + toolName: 'skill_view', + args: { name: 'hermes-agent' }, + result: { content: 'x'.repeat(chars) } +}) + +describe('messageStoreWeight', () => { + it('charges large text and tool results by character cost, not only part count', () => { + const text = [{ type: 'text', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 3) }] + + expect(messageStoreWeight(text)).toBe(4) + expect(messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 100)])).toBeGreaterThanOrEqual(101) + }) + + it('prices a 51KB tool output well above a plain exchange', () => { + const heavy = messageStoreWeight([bigResult(51_236)]) + const light = messageStoreWeight([{ type: 'text', text: 'ok' }]) + + expect(heavy).toBeGreaterThan(light * 50) + }) + + it('handles circular tool payloads without recursing forever', () => { + const result: { content: string; self?: unknown } = { content: 'ok' } + result.self = result + + expect(messageStoreWeight([{ type: 'tool-call', result }])).toBe(2) + }) + + it('bounds a single enormous payload', () => { + const enormous = messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 10_000)]) + + expect(enormous).toBeLessThanOrEqual(302) + }) +}) + +describe('messagePaintWeight', () => { + it('prices a settled activity row as the one line it renders, not its payload', () => { + const heavy = messagePaintWeight([bigResult(RENDER_WEIGHT_CHARS * 100)]) + + // The whole point: a collapsed tool row costs the same whether it wraps + // 200 bytes or 50KB, because the payload sits behind a closed disclosure. + expect(heavy).toBe(messagePaintWeight([bigResult(200)])) + expect(heavy).toBeLessThan(messageStoreWeight([bigResult(RENDER_WEIGHT_CHARS * 100)])) + }) + + it('charges a reasoning block one collapsed header', () => { + const thought = [{ type: 'reasoning', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 20) }] + + expect(messagePaintWeight(thought)).toBe(1) + }) + + it('charges rendered markdown its real character cost', () => { + const text = [{ type: 'text', text: 'x'.repeat(RENDER_WEIGHT_CHARS * 3) }] + + expect(messagePaintWeight(text)).toBe(4) + }) + + it('charges a diff by size — FileDiffPanel really does mount a row per line', () => { + const diff = Array.from({ length: 400 }, (_, i) => `+line ${i}`).join('\n') + + const patch = messagePaintWeight([ + { type: 'tool-call', toolName: 'patch', args: { path: 'a.ts' }, result: { inline_diff: diff } } + ]) + + expect(patch).toBeGreaterThan(5) + }) + + it('prices an image card flat, however long its data URL', () => { + const card = (chars: number) => [ + { type: 'tool-call', toolName: 'image_generate', args: {}, result: { image: `data:image/png;base64,${'A'.repeat(chars)}` } } + ] + + expect(messagePaintWeight(card(10_000_000))).toBe(messagePaintWeight(card(80))) + }) + + it('charges nothing for a row that renders nothing', () => { + const hoisted = [ + { type: 'tool-call', toolName: 'todo', args: { todos: Array.from({ length: 40 }, (_, i) => ({ content: `t${i}` })) } }, + { type: 'tool-call', toolName: 'react_to_message', args: { emoji: '❤️' } } + ] + + // Floors at 1: a message always occupies at least a row of the transcript. + expect(messagePaintWeight(hoisted)).toBe(1) + }) + + it('keeps a tool-heavy turn far cheaper to paint than to hold', () => { + // The measured shape behind the bad threshold: a dozen collapsed activity + // rows and a little prose. It paints as ~a dozen lines and used to be + // priced as an entire DOM page. + const parts = Array.from({ length: 12 }, () => bigResult(4_000)).concat([ + { type: 'text', text: 'x'.repeat(600) } as unknown as ReturnType + ]) + + expect(messagePaintWeight(parts)).toBeLessThan(messageStoreWeight(parts) / 5) + }) + + it('bounds a message of many enormous parts', () => { + const parts = Array.from({ length: 50 }, () => ({ + type: 'text', + text: 'x'.repeat(RENDER_WEIGHT_CHARS * 500) + })) + + // One ceiling for the whole message — not one per part. + expect(messagePaintWeight(parts)).toBeLessThanOrEqual(350) + }) +}) diff --git a/apps/desktop/src/lib/render-weight.ts b/apps/desktop/src/lib/render-weight.ts index 258a330d5cc08..9626c2e14aef9 100644 --- a/apps/desktop/src/lib/render-weight.ts +++ b/apps/desktop/src/lib/render-weight.ts @@ -1,3 +1,5 @@ +import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-class' + /** * Render cost of one message's content parts, in budget units. * @@ -6,12 +8,24 @@ * budget (how many of those actually render). Neither can be a message COUNT — * counting only parts underpriced a 51KB tool result as "1", so a handful of * huge results let a 600KB transcript through the old 300-part cap and drove - * Chromium's renderer into a GC crash. Characters approximate markdown - * parsing, text-node allocation, and tool-result formatting; parts approximate - * component/node count. + * Chromium's renderer into a GC crash (#55191). Characters approximate + * markdown parsing, text-node allocation, and tool-result formatting; parts + * approximate component/node count. * - * Shared so a heavy-but-short session is bounded by the same rule as a - * long-but-light one (#55191). + * The two layers do NOT price a part the same way, because they protect + * different things: + * + * - The STORE window protects the heap. Every message it admits is + * normalized into the runtime repository whether or not the transcript + * collapses it, so it prices the payload it has to hold: `messageStoreWeight`. + * - The DOM budget protects the paint, and what a turn paints is decided by + * the GROUPING, not by the bytes behind it. A settled run of twelve reads + * is one grey summary line, a thought is one collapsed disclosure, a + * `todo` is hoisted out of the transcript entirely, and a generated image + * is one `` however long its data URL. Charging those their payload + * had the budget counting hundreds of units of work that never mounts, so + * "Show earlier" appeared after two or three tool-heavy turns of a session + * that was painting almost nothing: `messagePaintWeight`. */ export const RENDER_WEIGHT_CHARS = 512 @@ -22,36 +36,53 @@ export const RENDER_WEIGHT_CHARS = 512 // payloads. const MAX_MEASURED_MESSAGE_CHARS = 300 * RENDER_WEIGHT_CHARS -const contentWeightCache = new WeakMap() +const storeWeightCache = new WeakMap() +const paintWeightCache = new WeakMap() const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type']) /** - * Estimate the synchronous renderer cost of one message's content array. + * What a collapsed row costs the DOM: one line of scaffolding. * - * A WeakMap keeps settled history O(message count) on later store updates; - * both assistant-ui and the session store publish a new content array when a - * streaming message changes, so the live tail still receives a fresh weight. + * A settled tool row is an icon, a title and maybe a count; a settled thought + * is a "Thought for 12s" header. Either one keeps its payload behind a + * disclosure, and an unopened disclosure mounts none of it. History always + * mounts collapsed, and history is exactly what "Show earlier" pages back + * through. */ -export function messageRenderWeight(content: unknown): number { - if (!Array.isArray(content)) { - return 1 - } +const COLLAPSED_ROW_WEIGHT = 1 - const cached = contentWeightCache.get(content) +/** + * What a fixed-size card costs the DOM. + * + * A generated image is one `` whether its result carries a path or a + * multi-megabyte data URL; a clarify question is a prompt and a few buttons; a + * delegation is a header over a one-line ticker. None of them scale with the + * payload, so charging characters priced a single image at more than a whole + * page of real turns. + */ +const CARD_WEIGHT = 6 - if (cached !== undefined) { - return cached - } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} +/** + * Character cost of an arbitrary payload, bounded and cycle-safe. + * + * `budget` is the characters still worth measuring. It is threaded through a + * whole message rather than reset per part, so a message of many huge parts + * cannot walk past the ceiling one part at a time. + */ +function payloadCharacters(roots: readonly unknown[], budget: number): number { const seen = new WeakSet() - const pending: unknown[] = [...content] + const pending: unknown[] = [...roots] let characters = 0 - while (pending.length > 0 && characters < MAX_MEASURED_MESSAGE_CHARS) { + while (pending.length > 0 && characters < budget) { const value = pending.pop() if (typeof value === 'string') { - characters += Math.min(value.length, MAX_MEASURED_MESSAGE_CHARS - characters) + characters += Math.min(value.length, budget - characters) continue } @@ -77,8 +108,111 @@ export function messageRenderWeight(content: unknown): number { } } - const weight = Math.max(1, content.length) + Math.ceil(characters / RENDER_WEIGHT_CHARS) - contentWeightCache.set(content, weight) + return characters +} + +/** Payload price: one unit per part, plus one per 512 characters it carries. */ +function payloadWeight(parts: readonly unknown[], budget: number): number { + return parts.length + Math.ceil(payloadCharacters(parts, budget) / RENDER_WEIGHT_CHARS) +} + +/** + * Estimate the cost of holding one message's content array in the runtime. + * + * A WeakMap keeps settled history O(message count) on later store updates; + * both assistant-ui and the session store publish a new content array when a + * streaming message changes, so the live tail still receives a fresh weight. + */ +export function messageStoreWeight(content: unknown): number { + if (!Array.isArray(content)) { + return 1 + } + + const cached = storeWeightCache.get(content) + + if (cached !== undefined) { + return cached + } + + const weight = Math.max(1, payloadWeight(content, MAX_MEASURED_MESSAGE_CHARS)) + storeWeightCache.set(content, weight) + + return weight +} + +/** + * What one part mounts, priced the way `message-parts.tsx` renders it. + * + * `measure` prices a payload against the message's shared character ceiling — + * only the parts that actually paint their content spend from it. + */ +function partPaintWeight(part: unknown, measure: (parts: readonly unknown[]) => number): number { + if (!isRecord(part)) { + return 1 + } + + // A thought mounts its header; the reasoning text sits behind it. + if (part.type === 'reasoning') { + return COLLAPSED_ROW_WEIGHT + } + + if (part.type !== 'tool-call') { + // Text is markdown the DOM really builds, so it keeps the payload price. + return measure([part]) + } + + const toolName = typeof part.toolName === 'string' ? part.toolName : '' + + if (isSilentTool(toolName)) { + return 0 + } + + if (!isCardTool(toolName)) { + return COLLAPSED_ROW_WEIGHT + } + + // A diff is the one card that scales: `FileDiffPanel` mounts a row per line, + // and a big patch really is the expensive thing in the turn. + return isFileEditTool(toolName) ? measure([part]) : CARD_WEIGHT +} + +/** + * Estimate what one message's content array actually MOUNTS in the transcript. + * + * Cached like the store weight: a settled message keeps its number across + * later store updates, and a streaming one publishes a fresh array per delta + * so the live tail is always re-measured. + */ +export function messagePaintWeight(content: unknown): number { + if (!Array.isArray(content)) { + return 1 + } + + const cached = paintWeightCache.get(content) + + if (cached !== undefined) { + return cached + } + + // One character ceiling for the whole message, not one per part — otherwise a + // message of many huge parts walks past it one part at a time. + let remaining = MAX_MEASURED_MESSAGE_CHARS + + const measure = (parts: readonly unknown[]) => { + const characters = payloadCharacters(parts, remaining) + remaining -= characters + + return parts.length + Math.ceil(characters / RENDER_WEIGHT_CHARS) + } + + let weight = 0 + + for (const part of content) { + weight += partPaintWeight(part, measure) + } + + weight = Math.max(1, weight) + paintWeightCache.set(content, weight) return weight }