mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
Adversarial-review follow-up to the S2 slice. The S1 rule froze ALL window recomputes while renderer.getSelection()?.isActive — but a finished mouse selection persists by design (boundary/renderer.ts keeps the highlight so Ctrl+C can re-copy), so a long streaming turn behind a lingering highlight ballooned the mounted set exactly like pre-windowing (and permanently ratcheted the Yoga-WASM high-water). Refinement: - full freeze only while selection.isDragging (the native walk touches the live tree on every drag update — destroying a row mid-walk corrupts the highlight; unchanged from S1 where it matters), - a finished highlight instead PINS the rows containing selection.selectedRenderables (parent-climb to the row wrapper via a WeakMap) as neverWindow — the highlight and a later Ctrl+C copy stay byte-exact while everything else keeps windowing, - an active highlight counts as activity (no idle measure churn under it). Test (headless mock-mouse drag): finished selection persists (isActive, !isDragging) → 300-row burst keeps peakMounted < 120 AND getSelectedText() returns the identical text afterward, the selected rows having been pinned while long scrolled past the margin. Verified on this build: gate digest otui-capped d5e9558583159eac… (2/2), mem2000 otui-capped windowing-ON vmhwm 312MB (target ≤ 350), scroll2000 otui-capped p50 2.0ms / p99 6.0ms (gate ≤ 17ms). check exit 0 (648 tests). Review verdicts on the remaining findings (verified against core source): - "scrollTop compensation race": rejected — scrollTop is an imperative scrollbar property (no signal staleness); records fire in document order, each compensation immediately visible to the next. - "heights map leak on /new": rejected — the countChanged cleanup prunes every per-key map against the live key set (test-verified). - remount-in-viewport estimate shift: only reachable when one frame jumps past the margin (> 1 viewport); the design's accepted "remounted for view" path — documented in the header. - expanded tool/reasoning re-collapse on far-remount: S1-accepted, deferred (component-local state; out of S2 file ownership).
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
/**
|
|
* Transcript windowing S1+S2 — headless integration (view/transcript.tsx +
|
|
* logic/window.ts behind HERMES_TUI_WINDOWING). Proves, against the REAL
|
|
* renderer tree:
|
|
* - out-of-window rows are actually UNMOUNTED (far fewer live renderables
|
|
* than the unwindowed tree — the spacer is 1 box, the row was ~3+ texts),
|
|
* - spacers are EXACT-height (total scrollHeight identical ON vs OFF — the
|
|
* zero-jank invariant),
|
|
* - scrolling far away REMOUNTS spaced-out rows (content paints again),
|
|
* - the flag OFF renders the legacy tree (no wrappers, everything mounted),
|
|
* and the S2 slices:
|
|
* - append-time adjudication: bursting 1500 appends keeps the PEAK
|
|
* simultaneously-mounted row count bounded (< 120) — rows scrolled past
|
|
* the margin become spacers without a manual scroll,
|
|
* - resume (commitSnapshot): a bulk snapshot mounts only the bottom window;
|
|
* everything above starts as estimate spacers and remounts on scroll-back,
|
|
* - the idle exact-measure march + spacer corrections are ZERO-JANK: with
|
|
* wrong estimates (soft-wrapped rows), idle pulses fix spacer heights
|
|
* while the visible frame stays byte-identical — sticky-bottom pinning
|
|
* compensates when pinned, explicit scrollTop compensation when reading
|
|
* mid-history.
|
|
*/
|
|
import { ScrollBoxRenderable, type Renderable } from '@opentui/core'
|
|
import { useRenderer } from '@opentui/solid'
|
|
import { afterEach, describe, expect, test } from 'vitest'
|
|
|
|
import { createSessionStore, type Message } from '../logic/store.ts'
|
|
import { resetWindowRowStats, windowRowStats } from '../logic/window.ts'
|
|
import { ThemeProvider } from '../view/theme.tsx'
|
|
import { Transcript } from '../view/transcript.tsx'
|
|
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
|
|
|
type Store = ReturnType<typeof createSessionStore>
|
|
|
|
const ENV_KEYS = ['HERMES_TUI_WINDOWING', 'HERMES_TUI_WINDOW_IDLE_MS'] as const
|
|
const envBefore = ENV_KEYS.map(k => process.env[k])
|
|
afterEach(() => {
|
|
ENV_KEYS.forEach((k, i) => {
|
|
const v = envBefore[i]
|
|
if (v === undefined) delete process.env[k]
|
|
else process.env[k] = v
|
|
})
|
|
})
|
|
|
|
/** Seed `n` settled one-line system rows (flat text — no async markdown). */
|
|
function seedRows(n: number): Store {
|
|
const store = createSessionStore()
|
|
store.apply({ type: 'gateway.ready' })
|
|
for (let i = 0; i < n; i++) store.pushSystem(`row-${i} marker`)
|
|
return store
|
|
}
|
|
|
|
function walk(node: Renderable, visit: (n: Renderable) => void): void {
|
|
visit(node)
|
|
for (const child of node.getChildren()) walk(child, visit)
|
|
}
|
|
|
|
interface Mounted {
|
|
probe: RenderProbe
|
|
count: () => number
|
|
scrollbox: () => ScrollBoxRenderable
|
|
}
|
|
|
|
async function mountTranscript(store: Store, windowing: '1' | '0'): Promise<Mounted> {
|
|
process.env.HERMES_TUI_WINDOWING = windowing
|
|
let root: Renderable | undefined
|
|
function Grab() {
|
|
root = useRenderer().root
|
|
return null
|
|
}
|
|
const probe = await renderProbe(
|
|
() => (
|
|
<ThemeProvider theme={() => store.state.theme}>
|
|
<Grab />
|
|
<Transcript store={store} />
|
|
</ThemeProvider>
|
|
),
|
|
{ width: 50, height: 12 }
|
|
)
|
|
// several passes: layout (heights recorded) → frame tick (window computed)
|
|
// → swap render — the driver is the renderer frame callback.
|
|
for (let i = 0; i < 6; i++) await probe.settle()
|
|
const count = () => {
|
|
let n = 0
|
|
if (root) walk(root, () => n++)
|
|
return n
|
|
}
|
|
const scrollbox = () => {
|
|
let sb: ScrollBoxRenderable | undefined
|
|
if (root) {
|
|
walk(root, node => {
|
|
if (node instanceof ScrollBoxRenderable) sb ??= node
|
|
})
|
|
}
|
|
if (!sb) throw new Error('no scrollbox in the mounted tree')
|
|
return sb
|
|
}
|
|
return { probe, count, scrollbox }
|
|
}
|
|
|
|
const ROWS = 120
|
|
|
|
describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => {
|
|
test('out-of-window rows unmount into exact-height spacers; OFF keeps the full tree', async () => {
|
|
const on = await mountTranscript(seedRows(ROWS), '1')
|
|
const off = await mountTranscript(seedRows(ROWS), '0')
|
|
try {
|
|
// sticky-bottom: both variants sit pinned at the bottom showing the tail.
|
|
expect(on.probe.frame()).toContain(`row-${ROWS - 1} marker`)
|
|
expect(off.probe.frame()).toContain(`row-${ROWS - 1} marker`)
|
|
|
|
// ZERO-JANK INVARIANT: spacers are exact-height — the windowed content
|
|
// is precisely as tall as the fully-mounted content.
|
|
expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
|
|
expect(on.scrollbox().scrollHeight).toBeGreaterThan(100) // sanity: way past the viewport
|
|
|
|
// The window actually sheds renderables: ~viewport±margin + bottom-30
|
|
// stay mounted out of 120 rows; the rest are 1-box spacers. The legacy
|
|
// tree keeps every row's text renderables alive.
|
|
expect(on.count()).toBeLessThan(off.count() * 0.6)
|
|
} finally {
|
|
on.probe.destroy()
|
|
off.probe.destroy()
|
|
}
|
|
})
|
|
|
|
test('scrolling far from the bottom remounts spaced-out rows (and the frame paints them)', async () => {
|
|
const on = await mountTranscript(seedRows(ROWS), '1')
|
|
try {
|
|
const sb = on.scrollbox()
|
|
// row-0 is far outside the bottom window: not painted while pinned.
|
|
expect(on.probe.frame()).not.toContain('row-0 marker')
|
|
sb.scrollTo(0)
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
expect(sb.scrollTop).toBe(0)
|
|
expect(on.probe.frame()).toContain('row-0 marker')
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
})
|
|
})
|
|
|
|
// ── S2 — append-time adjudication + windowed resume + idle exact-measure ──
|
|
|
|
/** Drop the right-most columns (scrollbar territory): corrections legitimately
|
|
* resize the thumb as estimates become exact — that is NOT content movement. */
|
|
function clipScrollbar(frame: string): string {
|
|
return frame
|
|
.split('\n')
|
|
.map(line => line.slice(0, -2).replace(/\s+$/, ''))
|
|
.join('\n')
|
|
}
|
|
|
|
describe('transcript windowing — S2 append-time adjudication', () => {
|
|
test('bursting 1500 appends keeps the peak mounted-row count bounded (< 120)', async () => {
|
|
const onStore = createSessionStore()
|
|
onStore.apply({ type: 'gateway.ready' })
|
|
const on = await mountTranscript(onStore, '1')
|
|
try {
|
|
resetWindowRowStats()
|
|
// Burst: 100 appends per frame — the per-append adjudication must window
|
|
// rows out as they pass the margin, NOT wait for the next frame tick.
|
|
for (let i = 0; i < 1500; i++) {
|
|
onStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`)
|
|
if (i % 100 === 99) await on.probe.settle()
|
|
}
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
expect(windowRowStats().peakMounted).toBeLessThan(120)
|
|
// pinned at the bottom: the tail painted (live rows mount instantly)
|
|
expect(on.probe.frame()).toContain('row-1499 marker')
|
|
|
|
// ZERO-JANK INVARIANT survives the burst: spacers (measured or
|
|
// estimated — these rows never soft-wrap) occupy EXACTLY the height the
|
|
// full tree would. (The store cap trims both to the same 1000 rows.)
|
|
const offStore = createSessionStore()
|
|
offStore.apply({ type: 'gateway.ready' })
|
|
for (let i = 0; i < 1500; i++) {
|
|
offStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`)
|
|
}
|
|
const off = await mountTranscript(offStore, '0')
|
|
try {
|
|
expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
|
|
} finally {
|
|
off.probe.destroy()
|
|
}
|
|
|
|
// scroll-to-top remounts burst rows that were never painted
|
|
const sb = on.scrollbox()
|
|
sb.scrollTo(0)
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
// the cap kept the newest 1000 rows → the oldest surviving row is 500
|
|
expect(on.probe.frame()).toContain('row-500 marker')
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
}, 120_000)
|
|
})
|
|
|
|
describe('transcript windowing — S2 selection: drag freezes, a finished highlight only pins its rows', () => {
|
|
test('a persisting (finished) selection does not freeze windowing; its rows stay mounted for copy', async () => {
|
|
const store = seedRows(60)
|
|
const on = await mountTranscript(store, '1')
|
|
try {
|
|
// drag-select across a visible row's TEXT line (rows interleave with
|
|
// margin lines — find one from the frame), then release — the highlight
|
|
// PERSISTS by design (boundary/renderer.ts keeps it so Ctrl+C re-copies).
|
|
const textY = on.probe
|
|
.frame()
|
|
.split('\n')
|
|
.findIndex(line => line.includes('marker'))
|
|
expect(textY).toBeGreaterThanOrEqual(0)
|
|
await on.probe.mouse.drag(3, textY, 30, textY + 2)
|
|
await on.probe.settle()
|
|
const selection = on.probe.renderer.getSelection()
|
|
expect(selection?.isActive).toBe(true)
|
|
expect(selection?.isDragging).toBe(false)
|
|
const copied = selection?.getSelectedText() ?? ''
|
|
expect(copied).toContain('marker')
|
|
|
|
// burst 300 appends while the highlight lingers: windowing must keep
|
|
// adjudicating (the S1 full freeze would balloon the mounted set)…
|
|
resetWindowRowStats()
|
|
for (let i = 0; i < 300; i++) {
|
|
store.pushSystem(`late-${i} marker`)
|
|
if (i % 50 === 49) await on.probe.settle()
|
|
}
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
expect(windowRowStats().peakMounted).toBeLessThan(120)
|
|
|
|
// …while the selected row — long scrolled out past the margin — stays
|
|
// PINNED: the highlight's renderables are alive and Ctrl+C still copies
|
|
// the exact same text.
|
|
expect(on.probe.renderer.getSelection()?.getSelectedText()).toBe(copied)
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
}, 60_000)
|
|
})
|
|
|
|
describe('transcript windowing — S2 windowed resume (commitSnapshot)', () => {
|
|
function snapshot(n: number): Message[] {
|
|
const out: Message[] = []
|
|
for (let i = 0; i < n; i++) {
|
|
if (i % 3 === 0) out.push({ role: 'user', text: `question-${i} marker` })
|
|
else if (i % 3 === 1) out.push({ role: 'assistant', text: `answer-${i} marker\nwith a second line` })
|
|
else out.push({ role: 'system', text: `note-${i} marker` })
|
|
}
|
|
return out
|
|
}
|
|
|
|
test('a bulk snapshot mounts only the bottom window; history starts as estimate spacers', async () => {
|
|
const store = createSessionStore()
|
|
store.apply({ type: 'gateway.ready' })
|
|
const on = await mountTranscript(store, '1')
|
|
try {
|
|
resetWindowRowStats()
|
|
store.hydrate(() => snapshot(600))
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
// Only the bottom window (+ bottom-30 sticky region) ever mounted — the
|
|
// 600-row snapshot must NOT transiently mount everything.
|
|
expect(windowRowStats().peakMounted).toBeLessThan(120)
|
|
expect(on.probe.frame()).toContain('answer-598 marker')
|
|
|
|
// estimate spacers above are exact for these unwrapped rows (incl. the
|
|
// ⧉ copy chip line on settled user/assistant rows) — scrollHeight
|
|
// matches the fully-mounted legacy tree.
|
|
const offStore = createSessionStore()
|
|
offStore.apply({ type: 'gateway.ready' })
|
|
const off = await mountTranscript(offStore, '0')
|
|
try {
|
|
offStore.hydrate(() => snapshot(600))
|
|
for (let i = 0; i < 6; i++) await off.probe.settle()
|
|
expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
|
|
} finally {
|
|
off.probe.destroy()
|
|
}
|
|
|
|
// scroll-back into never-mounted history remounts it
|
|
on.scrollbox().scrollTo(0)
|
|
for (let i = 0; i < 6; i++) await on.probe.settle()
|
|
expect(on.probe.frame()).toContain('question-0 marker')
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
}, 60_000)
|
|
})
|
|
|
|
describe('transcript windowing — S2 idle exact-measure + zero-jank corrections', () => {
|
|
// Every 4th row soft-wraps (~120 chars at width 50): the line-count estimate
|
|
// is WRONG (1 line vs 3), so the idle march must correct spacer heights —
|
|
// without ever moving visible content.
|
|
function wrappySeed(n: number): Store {
|
|
const store = createSessionStore()
|
|
store.apply({ type: 'gateway.ready' })
|
|
const long = 'wrap '.repeat(24).trim() // ~119 chars → 3 wrapped lines
|
|
for (let i = 0; i < n; i++) store.pushSystem(i % 4 === 0 ? `row-${i} ${long}` : `row-${i} marker`)
|
|
return store
|
|
}
|
|
|
|
// 400 rows: the mount itself runs ~10 frames (≈100 rows measured by the
|
|
// march before the baseline is captured) — enough unmeasured, wrongly-
|
|
// estimated history must REMAIN above for the assertions to bite.
|
|
const WRAPPY_ROWS = 400
|
|
|
|
test('pinned at the bottom: sticky pinning absorbs above-viewport corrections (frame is byte-stable)', async () => {
|
|
process.env.HERMES_TUI_WINDOW_IDLE_MS = '0' // pulse every idle frame
|
|
const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1')
|
|
try {
|
|
const sb = on.scrollbox()
|
|
const before = clipScrollbar(on.probe.frame())
|
|
const shBefore = sb.scrollHeight
|
|
// idle pulses march up the history, mounting+measuring 10 rows at a time
|
|
for (let i = 0; i < 50; i++) {
|
|
await on.probe.settle()
|
|
expect(clipScrollbar(on.probe.frame())).toBe(before) // ZERO jank, every pulse
|
|
}
|
|
// corrections actually happened: the wrapped rows were under-estimated
|
|
expect(sb.scrollHeight).toBeGreaterThan(shBefore)
|
|
// ...and converged to the legacy tree's exact total
|
|
const off = await mountTranscript(wrappySeed(WRAPPY_ROWS), '0')
|
|
try {
|
|
expect(sb.scrollHeight).toBe(off.scrollbox().scrollHeight)
|
|
} finally {
|
|
off.probe.destroy()
|
|
}
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
}, 120_000)
|
|
|
|
test('reading mid-history: above-viewport corrections compensate scrollTop in the same frame', async () => {
|
|
process.env.HERMES_TUI_WINDOW_IDLE_MS = '0'
|
|
const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1')
|
|
try {
|
|
const sb = on.scrollbox()
|
|
// leave the sticky pin and park mid-history (estimates above AND below)
|
|
sb.scrollTo(Math.floor(sb.scrollHeight / 2))
|
|
for (let i = 0; i < 6; i++) await on.probe.settle() // window remount settles
|
|
const baseline = clipScrollbar(on.probe.frame())
|
|
const scrollTopBefore = sb.scrollTop
|
|
for (let i = 0; i < 50; i++) {
|
|
await on.probe.settle()
|
|
expect(clipScrollbar(on.probe.frame())).toBe(baseline) // ZERO jank
|
|
}
|
|
// the under-estimated rows ABOVE the viewport grew; scrollTop was
|
|
// compensated by exactly that growth — that's WHY the frame held still.
|
|
expect(sb.scrollTop).toBeGreaterThan(scrollTopBefore)
|
|
} finally {
|
|
on.probe.destroy()
|
|
}
|
|
}, 120_000)
|
|
})
|