feat(desktop): price a turn by what it paints
One weight function served two budgets that 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 has to price the payload it holds. The DOM budget protects the paint, and what a turn mounts is decided by the grouping, not by the bytes behind it. Charging the DOM budget for payload made it count work that never happens. 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, and an image is one img however long its data URL — all of it priced as if fully expanded. messageStoreWeight keeps the payload price for the window. messagePaintWeight prices what mounts: collapsed rows flat, silent rows free, cards fixed, and markdown and diffs by size, since those really do build DOM. Both share one character ceiling per message rather than one per part.
This commit is contained in:
parent
7eb461d693
commit
31459ef0c9
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<typeof bigResult>
|
||||
])
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 `<img>` 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<object, number>()
|
||||
const storeWeightCache = new WeakMap<object, number>()
|
||||
const paintWeightCache = new WeakMap<object, number>()
|
||||
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 `<img>` 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<string, unknown> {
|
||||
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<object>()
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue