mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
opentui(harden): rolling message cap bounds the Yoga node high-water mark
This commit is contained in:
parent
76e9271dce
commit
c29402d731
2 changed files with 127 additions and 1 deletions
|
|
@ -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 `<For>` 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
|
||||
// `<For>` 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<StoreState>({
|
||||
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 `<For>` 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue