diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index aa9683300ac..8e3894ced84 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -254,6 +254,21 @@ function subagentStatusFor(type: string): string { } export function createSessionStore() { + // Rolling cap on retained transcript rows. OpenTUI lays out via Yoga (WASM), whose + // linear memory is grow-only — every live `` row is a Yoga-node subtree, so an + // uncapped `messages[]` ratchets the high-water mark up over a long session and never + // gives it back. Capping the array in place (see `capMessages`) makes Solid's keyed + // `` UNMOUNT exactly the evicted oldest rows → `Renderable.destroy()` → + // `yogaNode.free()`, returning those nodes to the WASM allocator's free list. + // + // 400 is generous (far more than a screenful) but bounded; opencode caps at 100. + // Read once per store from `HERMES_TUI_MAX_MESSAGES`. Turns trimmed beyond the cap + // aren't lost — they live on the gateway and are recoverable via `/resume`. + const MESSAGE_CAP = (() => { + const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10) + return Number.isFinite(raw) && raw > 0 ? raw : 400 + })() + const [state, setState] = createStore({ ready: false, messages: [], @@ -299,6 +314,16 @@ export function createSessionStore() { setState('theme', themeFromSkin(skin)) } + // Trim the transcript to MESSAGE_CAP, dropping the OLDEST rows IN PLACE via + // `splice` (NOT a `slice`-reassign). A keyed `` keeps rows by item + // REFERENCE, so splicing the head unmounts only the evicted rows (freeing their + // Yoga nodes) while the survivors keep their refs and are not remounted. A live + // streaming assistant turn is always the LAST row, so head-trimming never drops it. + function capMessages(draft: StoreState): void { + const overflow = draft.messages.length - MESSAGE_CAP + if (overflow > 0) draft.messages.splice(0, overflow) + } + // ── parts helpers (operate on a draft message inside produce) ─────────── function appendPart(m: Message, type: 'text' | 'reasoning', text: string): void { const parts = (m.parts ??= []) @@ -341,6 +366,7 @@ export function createSessionStore() { setState( produce(draft => { draft.messages.push({ role: 'user', text }) + capMessages(draft) }) ) } @@ -353,6 +379,7 @@ export function createSessionStore() { setState( produce(draft => { draft.messages.push({ role: 'system', text: clean }) + capMessages(draft) }) ) } @@ -361,6 +388,8 @@ export function createSessionStore() { function clearTranscript() { setState('messages', []) setState('subagents', []) + // Drop the dedup history too — a fresh transcript should re-process any id. + applied.clear() } /** Open / close the agents dashboard overlay (/agents). */ @@ -455,6 +484,7 @@ export function createSessionStore() { setState( produce(draft => { draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true }) + capMessages(draft) }) ) break @@ -658,6 +688,8 @@ export function createSessionStore() { /** Replace history with the resume snapshot, then replay events buffered meanwhile. */ function commitSnapshot(snapshot: Message[]): void { setState('messages', snapshot) + // An over-cap resume snapshot must be trimmed too (it sets the whole array). + setState(produce(capMessages)) const pending = buffering ?? [] buffering = null for (const event of pending) applyNow(event) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index c9d13a0cc1d..fb504ddd747 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -4,7 +4,7 @@ * `parts[]` model — text/tool interleave in one turn, tool start↔complete matched * by id and updated IN PLACE, `{output,exit_code}` envelope stripped. */ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import { DEFAULT_THEME } from '../logic/theme.ts' import { createSessionStore, type Message } from '../logic/store.ts' @@ -345,3 +345,97 @@ describe('session store — resume hydrate (Phase 4b)', () => { expect(store.state.messages[1]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live during resume' }) }) }) + +describe('session store — rolling message cap (bounds the Yoga node high-water mark)', () => { + const ENV_KEY = 'HERMES_TUI_MAX_MESSAGES' + const prev = process.env[ENV_KEY] + afterEach(() => { + if (prev === undefined) delete process.env[ENV_KEY] + else process.env[ENV_KEY] = prev + }) + + test('caps the message array at the env-tuned MESSAGE_CAP, dropping the oldest (head)', () => { + process.env[ENV_KEY] = '5' + const store = createSessionStore() + // push more than the cap; each distinct so we can tell which survived + for (let i = 0; i < 55; i++) store.pushUser(`msg ${i}`) + expect(store.state.messages).toHaveLength(5) + // the oldest 50 were sliced from the head; survivors are the last 5 (msg 50..54) + expect(store.state.messages[0]!.text).toBe('msg 50') + expect(store.state.messages.at(-1)!.text).toBe('msg 54') + }) + + test('pushSystem is also capped (head-dropped) at MESSAGE_CAP', () => { + process.env[ENV_KEY] = '3' + const store = createSessionStore() + for (let i = 0; i < 10; i++) store.pushSystem(`sys ${i}`) + expect(store.state.messages).toHaveLength(3) + expect(store.state.messages[0]!.text).toBe('sys 7') + expect(store.state.messages.at(-1)!.text).toBe('sys 9') + }) + + test('the in-flight streaming turn it opens at overflow SURVIVES the cap (head sliced, not tail)', () => { + process.env[ENV_KEY] = '4' + const store = createSessionStore() + // fill to the cap with user rows so the next push overflows + store.pushUser('u0') + store.pushUser('u1') + store.pushUser('u2') + store.pushUser('u3') // array now at the cap (4): [u0, u1, u2, u3] + expect(store.state.messages).toHaveLength(4) + + // message.start pushes the assistant turn as the LAST row (length 5) → head sliced to 4. + // The freshly-pushed streaming turn is the tail, so it must NOT be the one evicted. + store.apply({ type: 'message.start' }) + store.apply({ type: 'message.delta', payload: { text: 'in flight' } }) + expect(store.state.messages).toHaveLength(4) + expect(store.state.messages[0]!.text).toBe('u1') // 'u0' dropped from the head, not the tail turn + const live = store.state.messages.at(-1)! + expect(live.role).toBe('assistant') + expect(live.streaming).toBe(true) + expect(live.parts?.[0]).toMatchObject({ type: 'text', text: 'in flight' }) + }) + + test('message.start is capped: opening a turn beyond the cap drops the oldest', () => { + process.env[ENV_KEY] = '2' + const store = createSessionStore() + store.pushUser('a') + store.pushUser('b') + store.apply({ type: 'message.start' }) // array would be 3 → trimmed to 2 + expect(store.state.messages).toHaveLength(2) + expect(store.state.messages[0]!.text).toBe('b') // 'a' dropped from the head + expect(store.state.messages.at(-1)!.role).toBe('assistant') + }) + + test('commitSnapshot caps an over-cap resume snapshot (oldest history dropped)', () => { + process.env[ENV_KEY] = '3' + const store = createSessionStore() + const snapshot: Message[] = Array.from({ length: 8 }, (_, i) => ({ role: 'user', text: `h${i}` })) + store.beginBuffer() + store.commitSnapshot(snapshot) + expect(store.state.messages).toHaveLength(3) + expect(store.state.messages[0]!.text).toBe('h5') + expect(store.state.messages.at(-1)!.text).toBe('h7') + }) + + test('defaults to 400 when the env var is unset/invalid', () => { + delete process.env[ENV_KEY] + const store = createSessionStore() + for (let i = 0; i < 450; i++) store.pushUser(`m${i}`) + expect(store.state.messages).toHaveLength(400) + expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped + }) + + test('clearTranscript empties messages AND the applied dedup set', () => { + const store = createSessionStore() + store.pushUser('x') + // seed the dedup set with an id, then confirm it is now treated as seen + expect(store.duplicate('seen-1')).toBe(false) + expect(store.duplicate('seen-1')).toBe(true) + + store.clearTranscript() + expect(store.state.messages).toHaveLength(0) + // after clear the previously-seen id is processed again (the applied Set was cleared) + expect(store.duplicate('seen-1')).toBe(false) + }) +})