/** * 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: '' } }) 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: ', '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) }) })