Loading files
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { CommanderMirror, type CommanderFrame } from './commander.js'
import type { LiveEvent } from './normalize.js'
import { BODY_MAX_CHARS, RECAP_MAX_CHARS, deriveTurnSummary } from './summarize.js'
let dataDir = ''
describe('CommanderMirror recap events', () => {
beforeEach(() => {
vi.useFakeTimers()
dataDir = mkdtempSync(join(tmpdir(), 'adapter-commander-'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(dataDir, { recursive: true, force: true })
})
it('keeps what the user ASKED, not only what the agent answered', async () => {
// The router reads these to decide where a spoken follow-up belongs, and a recap answers the wrong
// question for that: measured on this desk, "Chiến tranh thế giới thứ hai kết thúc vào năm nào?"
// recapped to "1945." — a correct summary of the reply carrying not one word of the subject. Ask
// about the FIRST world war a minute later and nothing connects the two.
const mirror = new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => '1945.',
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'Chiến tranh thế giới thứ hai kết thúc vào năm nào?' } },
{ type: 'text_delta', payload: { content: 'The war ended in 1945.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-ask')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(mirror.recentAsks('session-ask')).toEqual(['Chiến tranh thế giới thứ hai kết thúc vào năm nào?'])
// The recap is still exactly what it was — this adds a list, it does not change one.
expect(mirror.recent('session-ask', 3)[0].recap).toBe('1945.')
})
it('keeps the question even when the turn produces no summary at all', async () => {
// The fault this replaced: the ask was written in the summariser's success branch, so a turn with
// no assistant text — or a summariser that returned null — left no record of what was asked.
// Measured on a real machine: three of eight agents had any questions on record, and the missing
// ones were the NEWEST, which is the signal the router most needs.
const mirror = new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'qua bong vang vietnam 2026 thuoc ve ai' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-nosummary')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(mirror.recentAsks('session-nosummary')).toEqual(['qua bong vang vietnam 2026 thuoc ve ai'])
// …and there is genuinely no recap for it. The question stands on its own.
expect(mirror.recent('session-nosummary', 3)).toEqual([])
})
it('keeps the three newest questions, newest first', async () => {
const mirror = new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
for (const q of ['one', 'two', 'three', 'four']) {
mirror.ingest([{ type: 'turn_started', payload: { userMessage: q } }] as LiveEvent[], 'session-many')
}
await vi.runAllTimersAsync()
expect(mirror.recentAsks('session-many')).toEqual(['four', 'three', 'two'])
})
it('fans a TodoWrite out to the device as a todo list, whatever engine produced it', () => {
// The device renders the checklist from `todos:[{c,s}]` folded onto a processing frame — nothing else
// carries it. Every engine reaches this through the SAME shape (`input.todos[{content,status}]`), which
// is why an engine whose planning tool is named differently silently loses its checklist: hermes calls
// its tool `todo`, and until that was mapped the device showed no list at all for it.
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'plan it' } },
{
type: 'tool_start',
payload: {
id: 't1',
tool: 'TodoWrite',
input: { todos: [
{ content: 'In hello', status: 'completed' },
{ content: 'In world', status: 'in_progress' },
{ content: 'Tổng kết', status: 'pending' },
] },
},
},
] as LiveEvent[], 'session-todos')
const withTodos = deviceFrames.filter((f) => Array.isArray((f.payload as { todos?: unknown }).todos))
expect(withTodos).toHaveLength(1)
expect((withTodos[0].payload as { todos: Array<{ c: string; s: string }> }).todos).toEqual([
{ c: 'In hello', s: 'completed' },
{ c: 'In world', s: 'in_progress' },
{ c: 'Tổng kết', s: 'pending' },
])
})
it('keeps the todo list on the busy tile through the heartbeat', () => {
// The device clears a busy tile on a watchdog; the 5s heartbeat has to re-assert the SAME list or the
// checklist would vanish mid-turn while the agent is still working on it.
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'plan it' } },
{ type: 'tool_start', payload: { id: 't1', tool: 'TodoWrite', input: { todos: [{ content: 'A', status: 'pending' }] } } },
] as LiveEvent[], 'session-hb')
deviceFrames.length = 0
expect(mirror.heartbeat('session-hb')).toBe(true)
expect(deviceFrames.some((f) => Array.isArray((f.payload as { todos?: unknown }).todos))).toBe(true)
})
it('feeds the device its sub-agent list, running then finished', () => {
// The firmware has always been able to draw this list (ui_project_set_agents) and never received one:
// nothing in the adapter, the node or the backend emitted `kind:'agents'`, for any engine. It renders
// each row verbatim, so the text and colour are decided here — `›` while running (the device scrolls
// that row into view), `✓ … · Ns` once done.
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'delegate it' } },
{ type: 'tool_start', payload: { id: 'a1', tool: 'Task', input: { description: 'audit the parser' } } },
// claude's own tool is named `Agent`, not `Task` — both must open a row (measured on a live turn).
{ type: 'tool_start', payload: { id: 'a2', tool: 'Agent', input: { description: 'write the tests' } } },
] as LiveEvent[], 'session-agents')
const rowsOf = (): Array<{ text: string; color: string }> => {
const last = deviceFrames.filter((f) => f.payload.kind === 'agents').pop()
return (last?.payload.agents ?? []) as Array<{ text: string; color: string }>
}
expect(rowsOf().map((r) => r.text)).toEqual(['› audit the parser', '› write the tests'])
mirror.ingest([
{ type: 'tool_end', payload: { id: 'a1', tool: 'Task', output: 'ok', isError: false, summary: 'done', durationSeconds: 12 } },
] as LiveEvent[], 'session-agents')
expect(rowsOf().map((r) => r.text)).toEqual(['✓ audit the parser · 12s', '› write the tests'])
expect(rowsOf()[0].color).not.toBe(rowsOf()[1].color) // finished and running must be tellable apart
})
it('carries a still-RUNNING sub-agent into the next turn and drops the finished ones', () => {
// Finished rows must not sit above "Working…" on the next turn — but a running one is not finished
// just because the user typed again: claude's async sub-agents outlive the turn that spawned them,
// and cursor announces a Task through its HOOK ~60ms BEFORE the turn opens (measured live).
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'one' } },
{ type: 'tool_start', payload: { id: 'a1', tool: 'Task', input: { description: 'first' } } },
{ type: 'tool_end', payload: { id: 'a1', tool: 'Task', output: 'done', isError: false, summary: '' } },
{ type: 'tool_start', payload: { id: 'a2', tool: 'Task', input: { description: 'still going' } } },
{ type: 'turn_started', payload: { userMessage: 'two' } },
{ type: 'tool_start', payload: { id: 'b1', tool: 'Task', input: { description: 'second' } } },
] as LiveEvent[], 'session-agents-2')
const last = deviceFrames.filter((f) => f.payload.kind === 'agents').pop()
expect((last?.payload.agents as Array<{ text: string }>).map((r) => r.text)).toEqual(['› still going', '› second'])
})
it('marks a sub-agent\'s turn end so the dial redraws the tile and tells nobody', async () => {
// An Orchestrator specialist (or the Director while specialists are still out): its `done`/`summary`
// carry `subagent: true`; the live stream does not, so the tile still moves.
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => 'Short recap\n\nLong body',
isSubagent: (sessionId) => sessionId === 'specialist',
dataDir,
})
for (const sessionId of ['specialist', 'director']) {
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'go' } },
{ type: 'text_delta', payload: { content: 'answered.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], sessionId)
}
await vi.runAllTimersAsync()
await Promise.resolve()
const of = (id: string) => deviceFrames.filter((f) => f.dbSessionId === id)
expect(of('specialist').map((f) => [f.payload.kind, f.payload.subagent])).toEqual([
['processing', undefined], ['processing', undefined], ['done', true], ['summary', true],
])
expect(of('director').every((f) => f.payload.subagent === undefined)).toBe(true)
})
it('keeps holding the turn end past the old cap while a sub-agent is still writing, then gives up silently', async () => {
// A fixed ten minutes released the hold under long sub-agents: one ring for a turn that was not
// over, another when it was. Now the sub-agents' transcripts are asked every minute.
const deviceFrames: CommanderFrame[] = []
let writing = true
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => 'Short recap\n\nLong body',
subagentActive: (_sessionId, agentId) => agentId === 'a1' && writing,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'delegate' } },
{ type: 'tool_start', payload: { id: 'a1', tool: 'Agent', input: { description: 'long job' } } },
{ type: 'tool_end', payload: { id: 'a1', tool: 'Agent', output: 'Async agent launched successfully.', isError: false, summary: '' } },
{ type: 'text_delta', payload: { content: 'Launched the long job.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-long')
const ends = () => deviceFrames.filter((f) => f.payload.kind === 'summary')
await vi.advanceTimersByTimeAsync(25 * 60_000)
expect(ends()).toHaveLength(0) // 25 minutes in, still writing: still held
writing = false
await vi.advanceTimersByTimeAsync(2 * 60_000)
await vi.runAllTimersAsync()
await Promise.resolve()
expect(ends()).toHaveLength(1)
// Given up on, not done — silent, so the person is not rung for an answer that never came.
expect(ends()[0].payload).toMatchObject({ subagent: true, recap: 'Short recap' })
})
it('a sub-agent that finishes releases the hold with an ordinary, audible summary', async () => {
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => 'Short recap\n\nLong body',
subagentActive: () => true,
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'delegate' } },
{ type: 'tool_start', payload: { id: 'a1', tool: 'Agent', input: { description: 'short job' } } },
{ type: 'turn_ended', payload: {} },
{ type: 'subagent_finished', payload: { id: 'a1', status: 'completed' } },
{ type: 'text_delta', payload: { content: 'Both done.' } },
] as LiveEvent[], 'session-short')
mirror.noteEngineStopped('session-short')
await vi.runAllTimersAsync()
await Promise.resolve()
const ends = deviceFrames.filter((f) => f.payload.kind === 'summary')
expect(ends).toHaveLength(1)
expect(ends[0].payload).not.toHaveProperty('subagent')
})
it('emits done before summary when recap succeeds', async () => {
const deviceFrames: CommanderFrame[] = []
const webFrames: Record<string, unknown>[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: (frame) => webFrames.push(frame),
hasDevice: () => true,
summarize: async () => 'Short recap\n\nLong body',
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'what happened?' } },
{ type: 'text_delta', payload: { content: 'The assistant answered.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-1')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(deviceFrames.map((f) => f.payload.kind)).toEqual([
'processing',
'processing',
'done',
'summary',
])
expect(deviceFrames.at(-1)?.payload).toEqual({ kind: 'summary', text: 'Long body', recap: 'Short recap' })
expect(webFrames.map((f) => f.type)).toEqual(['turn_summary_pending', 'turn_summary'])
})
it('with alwaysGenerate, persists a recap headless but streams NO cards to the absent device', async () => {
const deviceFrames: CommanderFrame[] = []
let summarizeCalls = 0
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => false, // no device connected
alwaysGenerate: true, // ...but generate + persist anyway
summarize: async () => { summarizeCalls++; return 'Scanned the LAN\n\nFound .143 root-equivalent.' },
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'scan the network' } },
{ type: 'text_delta', payload: { content: 'Found .143 — orangepi/orangepi, passwordless sudo.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-headless')
await vi.runAllTimersAsync()
await Promise.resolve()
// Generated + persisted → a programmatic client (agent.recent) sees it.
expect(summarizeCalls).toBe(1)
expect(mirror.recent('session-headless', 1)[0].recap).toBe('Scanned the LAN')
// But emit() stays device-gated: nothing rode the wire to a device that is not there.
expect(deviceFrames).toHaveLength(0)
})
it('without alwaysGenerate and no device, generates nothing (the original gate)', async () => {
let summarizeCalls = 0
const mirror = new CommanderMirror({
send: () => {}, sendWeb: () => {},
hasDevice: () => false,
summarize: async () => { summarizeCalls++; return 'x\n\ny' },
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'q' } },
{ type: 'text_delta', payload: { content: 'a' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-off')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(summarizeCalls).toBe(0)
expect(mirror.recent('session-off', 1)).toHaveLength(0)
})
it('alwaysGenerate may be a switch read per turn (the pair brain), and onSummary sees each stored recap', async () => {
let pairing = false
const summaries: Array<[string, { recap: string; body: string }]> = []
const mirror = new CommanderMirror({
send: () => {}, sendWeb: () => {},
hasDevice: () => false,
alwaysGenerate: () => pairing,
onSummary: (sessionId, summary) => summaries.push([sessionId, summary]),
summarize: async () => 'Fixed the flaky test\n\nPinned the clock in billing.spec.ts.',
dataDir,
})
const turn = [
{ type: 'turn_started', payload: { userMessage: 'fix it' } },
{ type: 'text_delta', payload: { content: 'done' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[]
mirror.ingest(turn, 'session-pair')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(summaries).toHaveLength(0)
pairing = true
mirror.ingest(turn, 'session-pair')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(summaries).toEqual([['session-pair', { recap: 'Fixed the flaky test', body: 'Pinned the clock in billing.spec.ts.' }]])
})
it('runs the recap once when a turn closes twice (Stop hook + watcher race)', async () => {
const deviceFrames: CommanderFrame[] = []
let summarizeCalls = 0
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => { summarizeCalls++; return 'Recap\n\nBody' },
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'who won?' } },
{ type: 'text_delta', payload: { content: 'Spain won.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-race')
// Second turn_ended for the SAME turn (the other of hook/watcher) — must NOT start a second recap.
mirror.ingest([{ type: 'turn_ended', payload: {} }] as LiveEvent[], 'session-race')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(summarizeCalls).toBe(1)
expect(deviceFrames.filter((f) => f.payload.kind === 'summary')).toHaveLength(1)
})
it('hands the summariser the previous turn\'s recap, and nothing on the first turn', async () => {
// recap = llm(instruct, previous_recap, ask, answer). The previous recap is read from the store
// BEFORE this turn's lands there — otherwise "previous" is the turn being summarised.
const previous: Array<string | undefined> = []
let turn = 0
const mirror = new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
summarize: async (_text, _signal, _ask, _sessionId, previousRecap) => {
previous.push(previousRecap)
turn++
return `Recap ${turn}\n\nBody ${turn}`
},
dataDir,
})
const oneTurn = (ask: string, answer: string) => mirror.ingest([
{ type: 'turn_started', payload: { userMessage: ask } },
{ type: 'text_delta', payload: { content: answer } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-prev')
oneTurn('fix the retry path', 'Fixed the retry path in client.ts.')
await vi.runAllTimersAsync()
await Promise.resolve()
oneTurn('same fix in the other file', 'Done, applied the same change to server.ts.')
await vi.runAllTimersAsync()
await Promise.resolve()
expect(previous).toEqual([undefined, 'Recap 1\n\nBody 1'])
expect(mirror.recent('session-prev', 3).map((r) => r.recap)).toEqual(['Recap 2', 'Recap 1'])
})
it('renders normalized Codex Task and child tools through the same device cards as Claude', () => {
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
mirror.ingest([
{
type: 'tool_start',
payload: {
id: 'task-1',
tool: 'Task',
input: { subagent_type: 'explorer', description: 'Inspect the API' },
},
},
{
type: 'tool_start',
payload: {
id: 'child-1',
tool: 'Bash',
input: { command: 'rg TODO' },
parentToolUseId: 'task-1',
},
},
], 'session-codex')
expect(deviceFrames.map((frame) => frame.payload)).toEqual([
{
kind: 'tool',
text: 'Task',
recap: 'Inspect the API',
color: '#d19a66',
detail: 'subagent_type: explorer · description: Inspect the API',
},
// …and the same Task also opens a row in the device's sub-agent list, which is a separate surface
// from the tool card: the card scrolls away with the feed, the list stays above "Working…".
{ kind: 'agents', agents: [{ text: '› Inspect the API', color: '#ff9d00' }] },
{
kind: 'tool',
text: 'Bash',
recap: 'rg TODO',
color: '#e5c07b',
detail: 'command: rg TODO',
},
])
})
it('heartbeat re-emits processing while a turn is open, nothing when idle', () => {
const deviceFrames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
dataDir,
})
// No such session → idle, emits nothing.
expect(mirror.heartbeat('nope')).toBe(false)
mirror.ingest([{ type: 'turn_started', payload: { userMessage: 'hi' } }] as LiveEvent[], 'session-hb')
deviceFrames.length = 0 // drop the turn_started processing frame
// Turn open → heartbeat re-asserts Processing and reports busy.
expect(mirror.heartbeat('session-hb')).toBe(true)
expect(deviceFrames).toEqual([{ type: 'commander_event', agentId: 'session-hb', dbSessionId: 'session-hb', payload: { kind: 'processing', text: 'Processing' } }])
})
it('heartbeat re-emits "Summarizing…" during the summarize window', async () => {
const deviceFrames: CommanderFrame[] = []
let releaseSummarize: (v: string) => void = () => {}
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: () => new Promise<string>((resolve) => { releaseSummarize = resolve }), // hangs until released
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'q' } },
{ type: 'text_delta', payload: { content: 'answer' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-sum')
await Promise.resolve() // let onTurnEnded kick off the (hanging) summarize → summarizing=true
deviceFrames.length = 0
// Turn closed but summarize in flight → heartbeat re-asserts Summarizing… and still reports busy.
expect(mirror.heartbeat('session-sum')).toBe(true)
expect(deviceFrames.map((f) => f.payload)).toEqual([{ kind: 'processing', text: 'Summarizing…' }])
releaseSummarize('recap\n\nbody')
await vi.runAllTimersAsync()
await Promise.resolve()
// Summary done → idle → heartbeat emits nothing and reports not busy.
deviceFrames.length = 0
expect(mirror.heartbeat('session-sum')).toBe(false)
expect(deviceFrames).toEqual([])
})
it('says so in the log when a turn closes that was never opened', async () => {
// The real failure this guards: a first prompt landing while the session is being attached has
// its turn_started folded into history, so the live turn's close arrives with turnOpen false.
// That used to return in silence — a whole turn produced no recap and no trace of why.
const errors: string[] = []
const error = vi.spyOn(console, 'error').mockImplementation((line: unknown) => { errors.push(String(line)) })
const deviceFrames: CommanderFrame[] = []
let summarizeCalls = 0
const mirror = new CommanderMirror({
send: (frame) => deviceFrames.push(frame),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => { summarizeCalls++; return 'Recap\n\nBody' },
dataDir,
})
mirror.ingest([{ type: 'turn_ended', payload: {} }] as LiveEvent[], 'session-orphan')
await vi.runAllTimersAsync()
expect(summarizeCalls).toBe(0)
expect(deviceFrames).toEqual([])
expect(errors.join('\n')).toContain('turn-end · DROPPED')
error.mockRestore()
})
it('stays silent for the ordinary duplicate close', async () => {
// Every turn closes twice (watcher + Stop hook). That path must not start logging.
const errors: string[] = []
const error = vi.spyOn(console, 'error').mockImplementation((line: unknown) => { errors.push(String(line)) })
const mirror = new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => 'Recap\n\nBody',
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'q' } },
{ type: 'text_delta', payload: { content: 'answer' } },
{ type: 'turn_ended', payload: {} },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'session-dup')
await vi.runAllTimersAsync()
expect(errors.join('\n')).not.toContain('DROPPED')
error.mockRestore()
})
})
describe('device frames address the agent, not the session', () => {
beforeEach(() => { vi.useFakeTimers(); dataDir = mkdtempSync(join(tmpdir(), 'adapter-commander-id-')) })
afterEach(() => { vi.useRealTimers(); rmSync(dataDir, { recursive: true, force: true }) })
it('sends agentId = the agent and dbSessionId = the engine session', () => {
// These two are different values now. The device routes tiles by `agentId` (stable across a `/clear`)
// and keeps `dbSessionId` to echo back when cancelling a turn; the backend also keys its voice queue
// on dbSessionId, so it must stay the engine session id.
const frames: CommanderFrame[] = []
const mirror = new CommanderMirror({
send: (f) => frames.push(f),
sendWeb: () => {},
hasDevice: () => true,
summarize: async () => null,
agentIdFor: (sessionId) => (sessionId === 'engine-session' ? 'agent-uuid' : undefined),
dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'go' } },
] as LiveEvent[], 'engine-session')
expect(frames[0]).toMatchObject({ agentId: 'agent-uuid', dbSessionId: 'engine-session' })
})
})
describe('recap lookup spans both ids', () => {
beforeEach(() => { vi.useFakeTimers(); dataDir = mkdtempSync(join(tmpdir(), 'adapter-recent-')) })
afterEach(() => { vi.useRealTimers(); rmSync(dataDir, { recursive: true, force: true }) })
it('stores a recap under the session but serves it for the agent', async () => {
// The device restores a tile by AGENT id; the recap is filed under the ENGINE session id so a
// `--resume` under a new agent still finds it. Ask with the wrong one and every tile came back empty.
const mirror = new CommanderMirror({
send: () => {}, sendWeb: () => {}, hasDevice: () => true,
summarize: async () => 'Short recap\n\nLong body', dataDir,
})
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'what happened?' } },
{ type: 'text_delta', payload: { content: 'It happened.' } },
{ type: 'turn_ended', payload: {} },
] as LiveEvent[], 'engine-session')
await vi.runAllTimersAsync()
expect(mirror.recent('engine-session')).toHaveLength(1)
// …and the daemon's provider resolves an agent id to that session before asking.
const resolve = (id: string) => (id === 'agent-uuid' ? 'engine-session' : id)
expect(mirror.recent(resolve('agent-uuid'))[0].recap).toBe('Short recap')
})
})
describe('CommanderMirror keeps the complete final answer beside the clipped one', () => {
// The answer that exposed this: a five-row event table whose intro line is the only part that survives
// deriveTurnSummary. A device reading it aloud stopped dead at "official sites:" and never reached a
// single event, because the stored body is flattened to one line and cut at 250 characters.
const ANSWER = [
'Here are five notable US events coming up in September–October 2026, with dates checked against official sites:',
'',
'| Event | Dates | Location |',
'| --- | --- | --- |',
'| US Open finals weekend | Sept. 12–13 | Queens, New York |',
'| State Fair of Texas | Sept. 25–Oct. 18 | Dallas, Texas |',
'| Austin City Limits Music Festival | Oct. 2–4 and 9–11 | Zilker Park, Austin, Texas |',
'| Albuquerque International Balloon Fiesta | Oct. 3–11 | Albuquerque, New Mexico |',
'| Great American Beer Festival | Oct. 10–11 | Denver, Colorado |',
].join('\n')
let dir = ''
beforeEach(() => { vi.useFakeTimers(); dir = mkdtempSync(join(tmpdir(), 'adapter-commander-full-')) })
afterEach(() => { vi.useRealTimers(); rmSync(dir, { recursive: true, force: true }) })
async function runTurn(mirror: CommanderMirror, sessionId: string, text: string): Promise<void> {
mirror.ingest([{ type: 'turn_started', payload: { userMessage: 'what is on' } }] as LiveEvent[], sessionId)
mirror.ingest([{ type: 'text_delta', payload: { content: text } }] as LiveEvent[], sessionId)
mirror.ingest([{ type: 'turn_ended', payload: {} }] as LiveEvent[], sessionId)
await vi.runOnlyPendingTimersAsync()
await vi.runOnlyPendingTimersAsync()
}
function build(dataDir: string): CommanderMirror {
return new CommanderMirror({
send: () => {},
sendWeb: () => {},
hasDevice: () => true,
// The shipped wiring: a local derivation, no model in the loop (cli.ts sets summarizeIsLocal).
summarize: async (text) => deriveTurnSummary(text),
summarizeIsLocal: true,
dataDir,
})
}
it('clips text to a preview while fullText keeps every event', async () => {
const mirror = build(dir)
await runTurn(mirror, 'session-full', ANSWER)
const [turn] = mirror.recent('session-full', 1) as Array<{ text: string; recap?: string; fullText?: string }>
expect(turn).toBeTruthy()
// What ships today, unchanged: one line, cut at the documented cap.
expect(turn.text.length).toBeLessThanOrEqual(BODY_MAX_CHARS)
expect(turn.text).not.toContain('Great American Beer Festival')
expect(turn.recap!.length).toBeLessThanOrEqual(RECAP_MAX_CHARS)
// What the new field adds: the answer as the person would read it on screen.
for (const event of ['US Open finals weekend', 'State Fair of Texas', 'Austin City Limits Music Festival',
'Albuquerque International Balloon Fiesta', 'Great American Beer Festival']) {
expect(turn.fullText).toContain(event)
}
// Structure survives too — the table is still a table, not one flattened run.
expect(turn.fullText).toContain('\n')
expect(mirror.lastFullText('session-full')).toBe(turn.fullText)
})
it('pairs each turn with its own answer, and survives a restart', async () => {
const mirror = build(dir)
await runTurn(mirror, 'session-two', 'First answer about the balloon fiesta.')
await runTurn(mirror, 'session-two', ANSWER)
const reloaded = build(dir)
const turns = reloaded.recent('session-two', 3) as Array<{ text: string; fullText?: string }>
expect(turns).toHaveLength(2)
// Newest first, and each row carries ITS OWN answer — the alignment bug this ordering invites.
expect(turns[0].fullText).toContain('Great American Beer Festival')
expect(turns[1].fullText).toBe('First answer about the balloon fiesta.')
})
it('truncates an oversized answer on a character boundary, not a byte one', async () => {
const mirror = build(dir)
// Every character is 3 bytes of UTF-8, so a byte-counted cut lands mid-sequence unless it is guarded.
await runTurn(mirror, 'session-big', 'Sự '.repeat(6000))
const full = mirror.lastFullText('session-big')!
expect(Buffer.byteLength(full, 'utf8')).toBeLessThanOrEqual(8192)
expect(full.endsWith('…')).toBe(true)
expect(full).not.toContain('�')
// A round trip through UTF-8 is lossless only if nothing was severed.
expect(Buffer.from(full, 'utf8').toString('utf8')).toBe(full)
})
})
describe('CommanderMirror keeps the open tool calls for the pair\'s floor', () => {
it('a call is open from its tool_start to its tool_end, and a turn boundary clears them all', () => {
const dir = mkdtempSync(join(tmpdir(), 'adapter-commander-tools-'))
const mirror = new CommanderMirror({ send: () => {}, sendWeb: () => {}, hasDevice: () => false, summarize: async () => null, dataDir: dir })
mirror.ingest([
{ type: 'turn_started', payload: { userMessage: 'test it' } },
{ type: 'tool_start', payload: { id: 't1', tool: 'Bash', input: { command: 'npm test', description: 'Run the tests' } } },
{ type: 'tool_start', payload: { id: 't2', tool: 'Read', input: { file_path: '/w/a.ts' } } },
{ type: 'tool_end', payload: { id: 't2', tool: 'Read', output: '', isError: false, summary: '' } },
] as LiveEvent[], 's1')
expect(mirror.openTools('s1')).toEqual([{ name: 'Bash', input: { command: 'npm test', description: 'Run the tests' } }])
mirror.ingest([{ type: 'turn_ended', payload: {} }] as LiveEvent[], 's1')
expect(mirror.openTools('s1')).toEqual([])
rmSync(dir, { recursive: true, force: true })
})
})
56ae2e47fThis file is pinned to the selected release. Download the original to open it in your design tool.