Files
hermes-agent/ui-opentui/src/test/store.test.ts
T

611 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Store test (spec v4 §5 Layer 3). Pure data behavior of the reducer: skin →
* theme, LRU dedup, hydrate-while-buffering (Phase 1); and the Phase 2b ordered
* `parts[]` model — text/tool interleave in one turn, tool start↔complete matched
* by id and updated IN PLACE, `{output,exit_code}` envelope stripped.
*/
import { afterEach, describe, expect, test } from 'vitest'
import { DEFAULT_THEME } from '../logic/theme.ts'
import { createSessionStore, type Message } from '../logic/store.ts'
describe('session store — theming / dedup / hydrate (Phase 1)', () => {
test('gateway.ready{skin} re-themes; default before', () => {
const store = createSessionStore()
expect(store.state.theme.brand.name).toBe(DEFAULT_THEME.brand.name)
store.apply({
type: 'gateway.ready',
payload: { skin: { branding: { agent_name: 'Zephyr' }, colors: { ui_primary: '#123456' } } }
})
expect(store.state.ready).toBe(true)
expect(store.state.theme.brand.name).toBe('Zephyr')
expect(store.state.theme.color.primary).toBe('#123456')
})
test('skin.changed updates the theme live', () => {
const store = createSessionStore()
store.apply({ type: 'skin.changed', payload: { branding: { agent_name: 'Aurora' } } })
expect(store.state.theme.brand.name).toBe('Aurora')
})
test('LRU dedup: duplicate(id) returns false once, true after', () => {
const store = createSessionStore()
expect(store.duplicate('evt-1')).toBe(false)
expect(store.duplicate('evt-1')).toBe(true)
expect(store.duplicate(undefined)).toBe(false) // no id → never deduped
})
test('hydrate replaces history, then replays events buffered mid-hydrate', () => {
const store = createSessionStore()
const snapshot: Message[] = [
{ role: 'user', text: 'old q' },
{ role: 'assistant', text: 'old a' }
]
// Simulate a live event arriving DURING hydrate by emitting inside loadSnapshot.
let emittedDuring = false
store.hydrate(() => {
if (!emittedDuring) {
emittedDuring = true
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'live!' } })
}
return snapshot
})
// snapshot (2) + the buffered live assistant turn (1) replayed after
expect(store.state.messages.length).toBe(3)
expect(store.state.messages[0]!.text).toBe('old q')
// the streamed assistant text now lives in an ordered text part
expect(store.state.messages[2]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live!' })
})
})
describe('session store — ordered parts (Phase 2b)', () => {
test('interleaves text → tool → text as ordered parts in one assistant turn', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'before ' } })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
// result_text is the {output,exit_code} JSON envelope — the store strips it.
store.apply({
type: 'tool.complete',
payload: { tool_id: 't1', result_text: '{"output":"hello\\nworld","exit_code":0}' }
})
store.apply({ type: 'message.delta', payload: { text: 'after' } })
store.apply({ type: 'message.complete' })
const msg = store.state.messages.at(-1)!
expect(msg.role).toBe('assistant')
expect(msg.streaming).toBe(false)
const parts = msg.parts ?? []
expect(parts.map(p => p.type)).toEqual(['text', 'tool', 'text'])
expect(parts[0]).toMatchObject({ type: 'text', text: 'before ' })
expect(parts[2]).toMatchObject({ type: 'text', text: 'after' })
const tool = parts[1]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.name).toBe('terminal')
expect(tool.resultText).toBe('hello\nworld') // envelope stripped
expect(tool.lineCount).toBe(2)
} else {
throw new Error('expected a tool part at index 1')
}
})
test('message.complete with text but NO prior start creates the turn (complete-only gateway; no drop)', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// no message.start / no deltas — straight to complete with the full text
store.apply({ type: 'message.complete', payload: { text: 'The whole answer.' } })
const msg = store.state.messages.at(-1)!
expect(msg.role).toBe('assistant')
expect(msg.streaming).toBe(false)
expect(msg.parts?.some(p => p.type === 'text' && p.text === 'The whole answer.')).toBe(true)
})
test('message.complete with no live turn and no text does NOT create an empty bubble', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.complete', payload: {} })
expect(store.state.messages.filter(m => m.role === 'assistant')).toHaveLength(0)
})
test('tool.complete updates the running tool part IN PLACE (not a new row)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'x', name: 'read_file' } })
expect(store.state.messages.at(-1)!.parts).toHaveLength(1)
expect(store.state.messages.at(-1)!.parts![0]).toMatchObject({ type: 'tool', state: 'running', name: 'read_file' })
store.apply({ type: 'tool.complete', payload: { tool_id: 'x', summary: 'read 42 lines' } })
const parts = store.state.messages.at(-1)!.parts!
expect(parts).toHaveLength(1) // updated in place — NOT appended as a separate row
const tool = parts[0]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.summary).toBe('read 42 lines')
} else {
throw new Error('expected a tool part')
}
})
test('captures tool args: context→argsPreview, args→argsText, duration, omitted note (item 2)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'terminal', context: 'ls -la src' } })
store.apply({
type: 'tool.complete',
payload: {
tool_id: 'a',
name: 'terminal',
args: { command: 'ls -la src' },
duration_s: 0.34,
result_text: '[showing verbose tail; omitted 3 lines / 90 chars]\nfile1\nfile2'
}
})
const tool = store.state.messages.at(-1)!.parts![0]!
if (tool.type !== 'tool') throw new Error('expected a tool part')
expect(tool.argsPreview).toBe('ls -la src') // primary-arg preview shown in the header (NOT overwritten)
expect(tool.argsText).toContain('"command"') // full args JSON for the expanded view
expect(tool.duration).toBe(0.34)
expect(tool.omittedNote).toBe('3 lines / 90 chars') // tidy note; raw label stripped
expect(tool.resultText).toBe('file1\nfile2') // clean body (label peeled)
expect(tool.lineCount).toBe(2)
})
test('tool.complete captures structured args into part.args (renderer registry feed)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'mcp_lookup' } })
store.apply({
type: 'tool.complete',
payload: { tool_id: 'a', args: { query: 'hermes', options: { depth: 2 } }, result_text: 'ok' }
})
const tool = store.state.messages.at(-1)!.parts![0]!
if (tool.type !== 'tool') throw new Error('expected a tool part')
expect(tool.args).toEqual({ query: 'hermes', options: { depth: 2 } }) // full structured dict
expect(tool.argsText).toContain('"query"') // stringified fallback still kept
})
test('derives resultText from the raw `result` when result_text is absent (non-verbose sessions)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'nv', name: 'terminal' } })
// non-verbose: no result_text — only the raw envelope-string `result`
store.apply({
type: 'tool.complete',
payload: { tool_id: 'nv', result: '{"output":"hi there\\nline two","exit_code":0,"error":null}' }
})
const tool = store.state.messages.at(-1)!.parts![0]!
if (tool.type !== 'tool') throw new Error('expected a tool part')
expect(tool.resultText).toBe('hi there\nline two') // envelope stripped, same pipeline
expect(tool.lineCount).toBe(2)
})
test('an object `result` is unwrapped too; result_text keeps precedence when present', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'o1', name: 'terminal' } })
store.apply({ type: 'tool.complete', payload: { tool_id: 'o1', result: { output: 'obj out', exit_code: 0 } } })
store.apply({ type: 'tool.start', payload: { tool_id: 'o2', name: 'terminal' } })
store.apply({
type: 'tool.complete',
payload: { tool_id: 'o2', result: 'raw fallback', result_text: 'verbose text' }
})
const parts = store.state.messages.at(-1)!.parts!
const first = parts[0]!
const second = parts[1]!
if (first.type !== 'tool' || second.type !== 'tool') throw new Error('expected tool parts')
expect(first.resultText).toBe('obj out') // object envelope → its output
expect(second.resultText).toBe('verbose text') // result_text still wins when sent
})
test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => {
const store = createSessionStore()
store.setCatalog({
tools: {
total: 42,
toolsets: [
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b', 3] },
{ name: 'off', count: 5, enabled: false, tools: [] },
{ name: '', count: 1 }
]
},
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
mcp: { servers: ['railway', 123, 'beeper'] },
junk: 'ignored'
})
const c = store.state.catalog!
expect(c.tools.total).toBe(42)
expect(c.tools.toolsets).toEqual([
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b'] }, // non-string tool dropped
{ name: 'off', count: 5, enabled: false, tools: [] } // enabled flag preserved
]) // nameless entry dropped
expect(c.skills.total).toBe(7)
expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped
})
test('setCatalog leaves the catalog unset on garbage / non-object input (decode → none)', () => {
const store = createSessionStore()
expect(store.state.catalog).toBeUndefined()
store.setCatalog('not an object')
expect(store.state.catalog).toBeUndefined()
store.setCatalog(null)
expect(store.state.catalog).toBeUndefined()
store.setCatalog(42)
expect(store.state.catalog).toBeUndefined()
})
test('setCatalog accepts a sparse but well-shaped catalog (absent sections default empty)', () => {
const store = createSessionStore()
store.setCatalog({ tools: { total: 3, toolsets: [{ name: 'core', count: 3, tools: ['a'] }] } })
const c = store.state.catalog!
expect(c.tools.total).toBe(3)
expect(c.tools.toolsets).toEqual([{ name: 'core', count: 3, enabled: true, tools: ['a'] }]) // enabled defaults on
expect(c.skills).toEqual({ total: 0, categories: [] }) // absent section → empty
expect(c.mcp.servers).toEqual([])
})
test('reasoning.delta accumulates into a reasoning part', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'reasoning.delta', payload: { text: 'thinking ' } })
store.apply({ type: 'reasoning.delta', payload: { text: 'hard' } })
const parts = store.state.messages.at(-1)!.parts ?? []
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
})
test('thinking.delta (kaomoji face) → transient status, NOT a transcript part; complete clears it', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'thinking.delta', payload: { text: '(´・_・`) formulating...' } })
expect(store.state.status).toBe('(´・_・`) formulating...')
expect(store.state.messages.at(-1)!.parts ?? []).toHaveLength(0) // no reasoning row from the face
store.apply({ type: 'message.delta', payload: { text: 'Hi!' } })
store.apply({ type: 'message.complete' })
expect(store.state.status).toBeUndefined() // cleared when the turn ends
// only the real reply text part remains — the face never entered the transcript
expect((store.state.messages.at(-1)!.parts ?? []).map(p => p.type)).toEqual(['text'])
})
test('status.update also drives the transient status line', () => {
const store = createSessionStore()
store.apply({ type: 'status.update', payload: { kind: 'tool', text: 'running terminal…' } })
expect(store.state.status).toBe('running terminal…')
})
})
describe('session store — blocking prompts (Phase 3)', () => {
test('approval.request sets an approval prompt; clearPrompt clears it', () => {
const store = createSessionStore()
expect(store.state.prompt).toBeUndefined()
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'delete temp' } })
expect(store.state.prompt).toMatchObject({ kind: 'approval', command: 'rm -rf /tmp/x', description: 'delete temp' })
store.clearPrompt()
expect(store.state.prompt).toBeUndefined()
})
test('clarify.request carries question + choices + request_id', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Which?', choices: ['a', 'b'], request_id: 'r1' } })
const p = store.state.prompt
expect(p).toMatchObject({ kind: 'clarify', question: 'Which?', requestId: 'r1' })
if (p?.kind === 'clarify') expect(p.choices).toEqual(['a', 'b'])
})
test('clarify.request with null choices → free-text only', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Name?', choices: null, request_id: 'r2' } })
const p = store.state.prompt
if (p?.kind === 'clarify') expect(p.choices).toBeNull()
})
test('sudo.request + secret.request set masked prompts', () => {
const store = createSessionStore()
store.apply({ type: 'sudo.request', payload: { request_id: 's1' } })
expect(store.state.prompt).toMatchObject({ kind: 'sudo', requestId: 's1' })
store.apply({ type: 'secret.request', payload: { env_var: 'API_KEY', prompt: 'Enter key', request_id: 's2' } })
expect(store.state.prompt).toMatchObject({ kind: 'secret', envVar: 'API_KEY', requestId: 's2' })
})
})
describe('session store — subagents (Phase 5e agents dashboard)', () => {
test('subagent.* events build + update a subagent by id', () => {
const store = createSessionStore()
store.apply({
type: 'subagent.start',
payload: { subagent_id: 'a1', goal: 'research X', model: 'haiku', depth: 1 }
})
expect(store.state.subagents).toHaveLength(1)
expect(store.state.subagents[0]).toMatchObject({ id: 'a1', goal: 'research X', status: 'running', depth: 1 })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search' } })
expect(store.state.subagents[0]).toMatchObject({ status: 'tool', lastTool: 'web_search' })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'found it' } })
expect(store.state.subagents).toHaveLength(1) // updated in place
expect(store.state.subagents[0]).toMatchObject({ status: 'complete', summary: 'found it' })
})
test('accumulates a live trace per subagent (item 15) + transient thought', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'crunch data' } })
store.apply({ type: 'subagent.thinking', payload: { subagent_id: 'a1', text: 'considering options' } })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
store.apply({ type: 'subagent.progress', payload: { subagent_id: 'a1', text: 'found 3 hits' } })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'done crunching' } })
const sa = store.state.subagents[0]!
// thinking text is transient (not in the trace), the rest is a concise log
expect(sa.thought).toBe('considering options')
expect(sa.trace).toEqual(['▶ crunch data', '⚡ web_search — opentui', 'found 3 hits', '✓ done crunching'])
})
test('clearTranscript also clears subagents', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'g' } })
store.clearTranscript()
expect(store.state.subagents).toHaveLength(0)
})
})
describe('session store — session chrome / status bar (item 14)', () => {
test('session.info populates model/effort/cwd/branch and nested usage context', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: {
model: 'anthropic/claude-opus-4-8',
reasoning_effort: 'high',
fast: true,
cwd: '/home/x/proj',
branch: 'main',
running: false,
usage: { context_used: 42000, context_max: 200000, context_percent: 21 }
}
})
const info = store.state.info
expect(info.model).toBe('anthropic/claude-opus-4-8')
expect(info.effort).toBe('high')
expect(info.fast).toBe(true)
expect(info.cwd).toBe('/home/x/proj')
expect(info.branch).toBe('main')
expect(info.contextPercent).toBe(21)
expect(info.contextMax).toBe(200000)
})
test('session.info reads context from TOP-LEVEL fields when there is no nested usage', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: { model: 'gpt-5.4', context_used: 1000, context_max: 8000, context_percent: 13, compressions: 2 }
})
const info = store.state.info
expect(info.model).toBe('gpt-5.4')
expect(info.contextUsed).toBe(1000)
expect(info.contextMax).toBe(8000)
expect(info.contextPercent).toBe(13)
expect(info.compressions).toBe(2)
})
test('session.info prefers nested usage.context_* over the top-level fallback', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: { context_percent: 5, usage: { context_percent: 88 } }
})
expect(store.state.info.contextPercent).toBe(88) // nested wins
})
test('session.info with a malformed payload does NOT crash and leaves chrome untouched (decode → none)', () => {
const store = createSessionStore()
store.applyInfo({ model: 'opus', cwd: '/p' })
// a wrong-typed field (model: number) fails the schema → empty patch, prior info survives
store.apply({ type: 'session.info', payload: { model: 123, usage: 'nope' } })
expect(store.state.info).toMatchObject({ model: 'opus', cwd: '/p' })
})
test('session.info with a partial payload only patches the present fields', () => {
const store = createSessionStore()
store.applyInfo({ model: 'opus', branch: 'main', running: true })
store.apply({ type: 'session.info', payload: { branch: 'dev' } }) // only branch present
expect(store.state.info).toMatchObject({ model: 'opus', branch: 'dev', running: true })
})
test('message.start sets running, message.complete clears it + refreshes usage', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
expect(store.state.info.running).toBe(true)
store.apply({ type: 'message.delta', payload: { text: 'hi' } })
store.apply({ type: 'message.complete', payload: { usage: { context_percent: 33 } } })
expect(store.state.info.running).toBe(false)
expect(store.state.info.contextPercent).toBe(33)
})
test('applyInfo merges a session.create info patch without clobbering prior fields', () => {
const store = createSessionStore()
store.applyInfo({ model: 'gpt-5.4', cwd: '/tmp' })
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' })
})
test('setHint sets/clears the transient composer hint (Ctrl+C again to quit — item 11)', () => {
const store = createSessionStore()
expect(store.state.hint).toBeUndefined()
store.setHint('Ctrl+C again to quit')
expect(store.state.hint).toBe('Ctrl+C again to quit')
store.setHint(undefined)
expect(store.state.hint).toBeUndefined()
})
})
describe('session store — gateway lifecycle / transport errors (auto-heal foundations)', () => {
test('gateway.exited clears the frozen running spinner AND pushes a system notice', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
expect(store.state.info.running).toBe(true) // a turn is in flight
store.apply({ type: 'gateway.exited' })
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
expect(store.state.info.running).toBe(false)
// Neutral status — "recovering…" now comes from gateway.recovering only.
expect(store.state.status).toBe('gateway exited')
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys).toHaveLength(1)
expect(sys[0]!.text).toContain('in-flight reply was lost')
})
test('gateway.exited enriches the notice with payload.reason when present', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137 } })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys[0]!.text).toContain('SIGKILL')
})
test('gateway.recovering reflects the attempt number in the status', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.recovering', payload: { attempt: 2 } })
expect(store.state.status).toBe('gateway recovering (attempt 2)…')
})
test('gateway.stderr is collected (NOT pushed to transcript), surfaced on start_timeout', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.stderr', payload: { line: 'ModuleNotFoundError: no module foo' } })
store.apply({ type: 'gateway.stderr', payload: { line: 'traceback line 2' } })
// chatty stderr never floods the transcript on its own
expect(store.state.messages).toHaveLength(0)
// …but the tail is surfaced when the gateway fails to start
store.apply({ type: 'gateway.start_timeout', payload: {} })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys).toHaveLength(1)
expect(sys[0]!.text).toContain('gateway failed to start')
expect(sys[0]!.text).toContain('ModuleNotFoundError')
})
test('gateway.protocol_error and error are surfaced to the transcript', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.protocol_error', payload: { preview: '<garbled>' } })
store.apply({ type: 'error', payload: { message: 'boom' } })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys.map(m => m.text)).toEqual(['gateway protocol error: <garbled>', 'error: boom'])
})
})
describe('session store — resume hydrate (Phase 4b)', () => {
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
const store = createSessionStore()
store.beginBuffer()
// a live event arrives DURING the (async) session.resume RPC
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'live during resume' } })
// the snapshot commits afterwards
store.commitSnapshot([{ role: 'user', text: 'old question' }])
expect(store.state.messages).toHaveLength(2) // snapshot(1) + the replayed assistant turn(1)
expect(store.state.messages[0]).toMatchObject({ role: 'user', text: 'old question' })
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)
expect(store.state.dropped).toBe(50) // head-sliced overflow is counted for the notice
// 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.dropped).toBe(5) // 8 snapshot 3 kept; resume SETS the count
expect(store.state.messages[0]!.text).toBe('h5')
expect(store.state.messages.at(-1)!.text).toBe('h7')
})
test('defaults to 3000 when the env var is unset/invalid', () => {
delete process.env[ENV_KEY]
const store = createSessionStore()
for (let i = 0; i < 3050; i++) store.pushUser(`m${i}`)
expect(store.state.messages).toHaveLength(3000)
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)
})
test('clearTranscript resets the dropped counter (the truncation notice clears)', () => {
process.env[ENV_KEY] = '2'
const store = createSessionStore()
for (let i = 0; i < 5; i++) store.pushUser(`m${i}`) // 5 pushed, cap 2 → 3 dropped
expect(store.state.dropped).toBe(3)
store.clearTranscript()
expect(store.state.dropped).toBe(0)
})
})