Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
ca042f11dThis file is pinned to the selected release. Download the original to open it in your design tool.
// The protocol, driven through a loopback port — no dial, no tmux, no network.
//
// What is worth testing here is not that JSON round-trips. It is the three rules the header names, each of
// which was paid for on real hardware and each of which is invisible until it is wrong:
//
// · every `hello` is answered, but only an unfamiliar dial gets the whole state pushed again
// · silence reopens the port, because a dead handle keeps accepting writes
// · a voice turn with no agent named goes through the router before it goes anywhere
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { CableDecoder, CableType, encodeCableFrame } from './cableFrame.js'
import { CableSession, type CableAgent, type CableHost, type CableMachine, type CablePort } from './cableSession.js'
import { DialLog } from './dialLog.js'
/** A port whose two ends are both in this process. */
class LoopbackPort implements CablePort {
readonly path = '/dev/loopback'
isOpen = true
/** Everything the daemon wrote, decoded back into messages. */
readonly sent: Array<Record<string, unknown>> = []
/** The raw bytes of every frame written, so a test can assert the 8 KB cap holds. */
readonly frames: Uint8Array[] = []
closedWith: string | null = null
private decoder = new CableDecoder()
constructor(
private readonly onData: (chunk: Buffer) => void,
private readonly onClosed: (why: string) => void = () => {},
) {}
async write(bytes: Uint8Array): Promise<void> {
this.frames.push(bytes)
this.decoder.feed(Buffer.from(bytes), (frame) => {
if (frame.type === CableType.Json) {
this.sent.push(JSON.parse(Buffer.from(frame.payload).toString('utf8')) as Record<string, unknown>)
}
})
}
async close(why = 'closed'): Promise<void> {
if (!this.isOpen) return
this.isOpen = false
this.closedWith = why
// SerialLink announces its own close; a fake that stays quiet makes "the dial went away" untestable.
this.onClosed(why)
}
/** Speak as the dial. */
say(msg: Record<string, unknown>): void {
this.onData(Buffer.from(encodeCableFrame(CableType.Json, Buffer.from(JSON.stringify(msg), 'utf8'))))
}
pcm(bytes: Buffer): void {
this.onData(Buffer.from(encodeCableFrame(CableType.Pcm, bytes)))
}
/** A framed ESP_LOG line, as the dial sends while the daemon holds its only port. */
logLine(text: string): void {
this.onData(Buffer.from(encodeCableFrame(CableType.Log, Buffer.from(text, 'utf8'))))
}
/** Only the `t` of each message the daemon sent, in order. */
types(): string[] {
return this.sent.map((m) => m.t as string)
}
}
const LOCAL_ROW: CableMachine = { id: 'mac-local', name: 'MacBook Pro', state: 'ready', local: true }
const AGENTS: CableAgent[] = [
{ id: 'a1', name: 'Fix login screen', engine: 'claude' },
{ id: 'a2', name: 'Device firmware voice', engine: 'codex' },
]
function makeHost(over: Partial<CableHost> = {}) {
const host: CableHost = {
localMachine: () => ({ id: 'mac-local', name: 'MacBook Pro' }),
listMachines: async () => ({ machines: [LOCAL_ROW], source: 'backend' as const }),
selectedMachine: () => 'mac-local',
selectMachine: async () => ({ ok: true as const }),
listSwarms: () => ({ selected: '', swarms: [], tiles: [] }),
listUnread: () => [],
selectSwarm: vi.fn(),
appName: () => 'harness',
voiceLang: () => 'en',
listAgents: async () => AGENTS,
agentTotal: () => AGENTS.length,
activeSwarm: () => 't1',
describe: (id) => { const a = AGENTS.find((x) => x.id === id); return a ? { name: a.name, engine: a.engine ?? '', machine: a.machine ?? '' } : undefined },
sendTurn: vi.fn(),
stopTurn: vi.fn(),
scrolled: vi.fn(),
answer: vi.fn(),
focus: vi.fn(),
openAgent: vi.fn(),
forkAgent: async (id) => ({ ok: true as const, agentId: `${id}-fork` }),
updateAgent: vi.fn(),
listModels: async () => ['runtime-v1:s1:claude:opus@high', 'runtime-v1:s1:claude:sonnet@low'],
recentSummaries: async () => [],
transcribe: async () => 'fix the login screen',
route: async () => ({ agentId: 'a1', confidence: 0.9, reason: 'name matched' }),
log: () => {},
...over,
}
return host
}
/** Start a session on a loopback and hand back both ends. Never touches the real serial layer. */
/** A throwaway log directory per session, so no test reads another's lines. */
function tmpLog() { return new DialLog(mkdtempSync(join(tmpdir(), 'cable-'))) }
async function connect(host: CableHost = makeHost(), log = tmpLog()) {
let port!: LoopbackPort
const session = new CableSession(host, log, async (onData, onClosed) => {
port = new LoopbackPort(onData, onClosed)
return port
})
session.start()
await vi.waitFor(() => expect(port).toBeDefined())
return { session, port, host }
}
/** Let the microtask queue drain — every send is async. */
const settle = () => new Promise((r) => setTimeout(r, 0))
describe('cable session', () => {
it('recovers the selected terminal footer without a transcript start event and bounds captures', async () => {
const activityText = vi.fn(async () => 'Coalescing...')
const { session, port } = await connect(makeHost({ activityText }))
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
await session.focusAgent('a1')
port.sent.length = 0
await session['refreshFocusedActivity']()
expect(port.sent).toEqual([
{ t: 'turn.started', agentId: 'a1', text: 'Coalescing...' },
{ t: 'turn.activity', agentId: 'a1', text: 'Coalescing...' },
])
await session['refreshFocusedActivity']()
expect(activityText).toHaveBeenCalledTimes(1)
// A different selected pane is checked immediately, without forwarding the old word.
activityText.mockResolvedValueOnce('Working')
await session.focusAgent('a2')
await session['refreshFocusedActivity']()
expect(port.sent.at(-1)).toEqual({ t: 'turn.activity', agentId: 'a2', text: 'Working' })
} finally { await session.stop() }
})
it.each(['done', 'summary', 'error', 'focus', 'disconnect'])(
'does not recover stale work if %s happens during a footer capture', async end => {
let finish!: (text: string | null) => void
const activityText = vi.fn(() => new Promise<string | null>(resolve => { finish = resolve }))
const { session, port } = await connect(makeHost({ activityText }))
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
await session.focusAgent('a1')
port.sent.length = 0
const pending = session['refreshFocusedActivity'](); await settle()
await session['refreshFocusedActivity']()
expect(activityText).toHaveBeenCalledTimes(1)
if (end === 'done') await session.turnDone('a1')
else if (end === 'summary') await session.summary('a1', 'Completed.', 'Completed. All checks passed.')
else if (end === 'error') await session.turnError('a1', 'Stopped')
else if (end === 'focus') await session.focusAgent('a2')
else await session.stop()
finish('Coalescing...'); await pending
expect(port.types()).not.toContain('turn.activity')
expect(port.types()).not.toContain('turn.started')
if (end === 'done' || end === 'summary' || end === 'error') {
await session['refreshFocusedActivity']()
expect(activityText).toHaveBeenCalledTimes(1)
}
} finally { await session.stop() }
})
it('does not read before connection or invent liveness when no native footer exists', async () => {
const activityText = vi.fn(async () => null)
const { session, port } = await connect(makeHost({ activityText }))
try {
await session.focusAgent('a1')
await session['refreshFocusedActivity']()
expect(activityText).not.toHaveBeenCalled()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
await session['refreshFocusedActivity']()
expect(port.sent).toEqual([{ t: 'turn.activity', agentId: 'a1', text: '' }])
} finally { await session.stop() }
})
it('sends the exact terminal activity without delaying turn liveness', async () => {
let finish!: (text: string | null) => void
const { session, port } = await connect(makeHost({ activityText: () => new Promise(resolve => { finish = resolve }) }))
try {
const pending = session.turnStarted('a1', 'Processing'); await settle()
expect(port.sent).toEqual([{ t: 'turn.started', agentId: 'a1', text: 'Processing' }])
finish('Coalescing...'); await pending
expect(port.sent.at(-1)).toEqual({ t: 'turn.activity', agentId: 'a1', text: 'Coalescing...' })
} finally { await session.stop() }
})
it.each(['done', 'summary', 'error', 'disconnect'])(
'drops a terminal activity read arriving after %s', async end => {
let finish!: (text: string | null) => void
const { session, port } = await connect(makeHost({ activityText: () => new Promise(resolve => { finish = resolve }) }))
try {
const pending = session.turnStarted('a1', 'Processing'); await settle()
if (end === 'done') await session.turnDone('a1')
else if (end === 'summary') await session.summary('a1', 'Done.', 'Done. All checks passed.')
else if (end === 'error') await session.turnError('a1', 'Stopped')
else await session.stop()
finish('Coalescing...'); await pending
expect(port.sent.some(message => message.t === 'turn.activity')).toBe(false)
} finally { await session.stop() }
})
it('keeps a newer activity read and clears a disappeared footer without inventing a label', async () => {
const finishes: Array<(text: string | null) => void> = []
const { session, port } = await connect(makeHost({ activityText: () => new Promise(resolve => finishes.push(resolve)) }))
try {
const older = session.turnStarted('a1', 'Processing'); await settle()
const newer = session.turnStarted('a1', 'Processing'); await settle()
finishes[1]('Boogieing...'); await newer
finishes[0]('Coalescing...'); await older
expect(port.sent.filter(message => message.t === 'turn.activity').map(message => message.text)).toEqual(['Boogieing...'])
const cleared = session.turnStarted('a1', 'Processing'); await settle()
finishes[2](null); await cleared
expect(port.sent.at(-1)).toEqual({ t: 'turn.activity', agentId: 'a1', text: '' })
} finally { await session.stop() }
})
it('extends a live short recap from the supplied body', async () => {
const { session, port } = await connect()
try {
await session.summary('a1', 'Yes.', 'Yes. The fix is installed.')
expect(port.sent.at(-1)).toMatchObject({ t: 'summary', recap: 'Yes. The fix is installed.', text: 'Yes. The fix is installed.' })
} finally { await session.stop() }
})
it('does not restore an old inbox after a newer empty snapshot', async () => {
let finish!: (value: Array<{ recap: string; text: string }>) => void
const history = new Promise<Array<{ recap: string; text: string }>>(resolve => { finish = resolve })
const { session, port } = await connect(makeHost({ recentSummaries: () => history }))
try {
const older = session.replaceNotifications([{ agentId: 'a1', machineId: 'mac-local', question: false, text: '' }])
await session.replaceNotifications([])
finish([{ recap: 'Old result.', text: 'Old result.' }]); await older
expect(port.sent).toEqual([{ t: 'notif.replace', items: [] }])
} finally { await session.stop() }
})
it('keeps a completion seen during a slow restore cleared without losing other questions', async () => {
let finish!: (value: Array<{ recap: string; text: string }>) => void
const history = new Promise<Array<{ recap: string; text: string }>>(resolve => { finish = resolve })
const { session, port } = await connect(makeHost({ recentSummaries: () => history }))
try {
const older = session.replaceNotifications([
{ agentId: 'a1', machineId: 'mac-local', question: false, text: '' },
{ agentId: 'a2', machineId: 'mac-local', question: true, text: 'Which branch?' },
])
await session.agentSeen('a1')
await session.agentSeen('a2')
finish([{ recap: 'Old result.', text: 'Old result.' }]); await older
expect(port.sent.at(-1)).toMatchObject({ t: 'notif.replace', items: [
{ agentId: 'a2', question: true, summary: 'Which branch?' },
] })
// A later completion for the same agent is new news, not permanently suppressed.
await session.replaceNotifications([{ agentId: 'a1', machineId: 'mac-local', question: false, text: 'New result.' }])
expect(port.sent.at(-1)).toMatchObject({ t: 'notif.replace', items: [
{ agentId: 'a1', question: false, summary: 'New result.' },
] })
} finally { await session.stop() }
})
it('fetches the chosen agent question and submits its reviewed token once', async () => {
const answerReviewed = vi.fn<NonNullable<CableHost['answerReviewed']>>(async () => ({ ok: true as const }))
const { session, port } = await connect(makeHost({ answerReviewed }))
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
const questions = [{ key: 'scope', q: 'Which scope?', options: ['File', 'Project'], multi: false }]
await session.question('a1', 'first', questions)
await session.question('a2', 'second', questions)
port.say({ t: 'question.read', agentId: 'a1', requestId: 'read-1' })
await vi.waitFor(() => expect(port.types()).toContain('question.state'))
const state = port.sent.find(m => m.t === 'question.state')!
expect(state).toMatchObject({ ok: true, agentId: 'a1', id: 'first', requestId: 'read-1' })
const submit = { t: 'answer.reviewed', agentId: 'a1', requestId: 'send-1', token: state.token, choices: [2] }
port.say(submit); port.say(submit)
await vi.waitFor(() => expect(port.types().filter(t => t === 'answer.receipt')).toHaveLength(2))
expect(answerReviewed).toHaveBeenCalledTimes(1)
expect(answerReviewed.mock.calls[0][0]).toMatchObject({ agentId: 'a1', requestId: 'first', answers: { scope: 'Project' } })
await session.questionClose('a1', 'first')
port.say(submit); await settle()
expect(answerReviewed).toHaveBeenCalledTimes(1)
} finally { await session.stop() }
})
it('carries exact text to another explicit recipient, and consumes it only when sending', async () => {
const selectPassage = vi.fn<NonNullable<CableHost['selectPassage']>>(async command => command.op === 'pin'
? { ok: true, selectionId: 'pick-1', revision: 4, rows: 2, extending: true,
excerpt: 'source output', text: ' source line\nsecond line' }
: { ok: false, error: 'Closed' })
const host = makeHost({ selectPassage, transcribe: vi.fn(async () => 'Compare this with your plan.'), route: vi.fn() })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'carry.prepare', carryId: 'carry-1', requestId: 'carry-request', agentId: 'a1', selectionId: 'pick-1', revision: 3 })
await vi.waitFor(() => expect(port.types()).toContain('carry.state'))
expect(port.sent.find(x => x.t === 'carry.state')).toMatchObject({ active: true, carryId: 'carry-1', rows: 2 })
expect(port.sent.find(x => x.t === 'carry.state')).not.toHaveProperty('text')
expect(host.sendTurn).not.toHaveBeenCalled()
port.say({ t: 'voice.begin', uploadId: 'carried', agentId: 'a2', carryId: 'carry-1' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.sendTurn).toHaveBeenCalledOnce())
const sent = vi.mocked(host.sendTurn).mock.calls[0]!
expect(sent[0]).toBe('a2')
expect(sent[1]).toContain('Compare this with your plan.\n\nContext I selected from harness ')
expect(sent[1]).toContain('> source line\n> second line')
expect(host.route).not.toHaveBeenCalled()
expect(port.sent.find(x => x.t === 'voice.transcript')).toMatchObject({ carryId: 'carry-1', agentId: 'a2' })
port.say({ t: 'voice.begin', uploadId: 'duplicate', agentId: 'a2', carryId: 'carry-1' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.sent.some(x => x.t === 'voice.error' && x.uploadId === 'duplicate')).toBe(true))
expect(host.sendTurn).toHaveBeenCalledOnce()
} finally { await session.stop() }
})
it.each([{}, { carryId: null }, { carryId: 'expired', agentId: 'a2' },
{ carryId: 'expired', formId: 'f', formRevision: 1 }, { carryId: 'expired', agentId: 'a2', selectionId: 'pick' }])(
'invalid carried voice never falls back to bare words or inferred routing: %j', async fields => {
const host = makeHost({ transcribe: vi.fn(), route: vi.fn(), routeInWindow: vi.fn() })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'voice.begin', uploadId: 'invalid-carry', carryId: 'missing', ...fields })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(host.transcribe).not.toHaveBeenCalled()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.route).not.toHaveBeenCalled()
expect(host.routeInWindow).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('dropping carried text during transcription prevents later delivery', async () => {
let finish!: (s: string) => void
const host = makeHost({
selectPassage: async command => command.op === 'pin'
? { ok: true, selectionId: 'p', revision: 2, rows: 1, extending: false, excerpt: 'source', text: 'source' }
: { ok: false, error: 'Closed' },
transcribe: vi.fn(() => new Promise<string>(resolve => { finish = resolve })),
})
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'carry.prepare', carryId: 'carry-drop', requestId: 'r', agentId: 'a1', selectionId: 'p', revision: 1 })
await vi.waitFor(() => expect(port.types()).toContain('carry.state'))
port.say({ t: 'voice.begin', uploadId: 'drop', agentId: 'a2', carryId: 'carry-drop' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.transcribe).toHaveBeenCalledOnce())
port.say({ t: 'carry.cancel', carryId: 'carry-drop' }); await settle()
finish('Explain this'); await settle()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(port.types()).not.toContain('voice.transcript')
} finally { await session.stop() }
})
it('a refused recipient keeps the passage for an explicit retry', async () => {
const host = makeHost({
selectPassage: async command => command.op === 'pin'
? { ok: true, selectionId: 'p', revision: 2, rows: 1, extending: false, excerpt: 'source', text: 'source' }
: { ok: false, error: 'Closed' },
sendTurn: vi.fn().mockReturnValueOnce({ ok: false, reason: 'offline' }).mockReturnValue({ ok: true }),
})
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'carry.prepare', carryId: 'carry-retry', requestId: 'r', agentId: 'a1', selectionId: 'p', revision: 1 })
await vi.waitFor(() => expect(port.types()).toContain('carry.state'))
port.say({ t: 'voice.begin', uploadId: 'refused', agentId: 'a2', carryId: 'carry-retry' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(port.types()).not.toContain('voice.transcript')
expect(host.sendTurn).toHaveBeenCalledOnce()
port.say({ t: 'voice.begin', uploadId: 'retry', agentId: 'a2', carryId: 'carry-retry' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.transcript'))
expect(host.sendTurn).toHaveBeenCalledTimes(2)
expect(vi.mocked(host.sendTurn).mock.calls[1]![1]).toContain('> source')
} finally { await session.stop() }
})
it('routes form choices without terminal input and acknowledges the exact device request', async () => {
const form = vi.fn<NonNullable<CableHost['form']>>(async () => ({
ok: true, active: true, revision: 2, label: 'Codex', position: 1, total: 3, busy: false, enabled: true }))
const host = makeHost({ form }), { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'form', op: 'move', formId: 'form-one', requestId: 'form-2', revision: 1, delta: 1 })
await vi.waitFor(() => expect(port.sent.some(x => x.t === 'form.state')).toBe(true))
expect(form).toHaveBeenCalledExactlyOnceWith({ op: 'move', formId: 'form-one', revision: 1, delta: 1 })
expect(port.sent.find(x => x.t === 'form.state')).toMatchObject({ requestId: 'form-2', formId: 'form-one', label: 'Codex' })
expect(host.openAgent).not.toHaveBeenCalled()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.answer).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('visits and returns with correlated acknowledgements and no terminal actions', async () => {
const visit = vi.fn<NonNullable<CableHost['visit']>>(async command => ({
ok: true, active: command.op === 'open', agentId: command.op === 'open' ? 'a2' : 'a1', label: 'My work' }))
const host = makeHost({ visit })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'visit', op: 'open', visitId: 'visit-one', requestId: 'open-one', agentId: 'a2' })
await vi.waitFor(() => expect(port.sent.some(x => x.t === 'visit.state')).toBe(true))
expect(port.sent.find(x => x.t === 'visit.state')).toMatchObject({ requestId: 'open-one', visitId: 'visit-one', active: true, agentId: 'a2' })
port.say({ t: 'visit', op: 'back', visitId: 'visit-one', requestId: 'back-one' }); await settle()
expect(port.sent.find(x => x.requestId === 'back-one')).toMatchObject({ active: false, agentId: 'a1' })
expect(host.openAgent).not.toHaveBeenCalled()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.answer).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('latest-output requests use the visit receipt and never become agent input', async () => {
const visit = vi.fn<NonNullable<CableHost['visit']>>(async () => ({
ok: true, active: true, agentId: 'a1', label: 'Your reading' }))
const host = makeHost({ visit })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
port.say({ t: 'visit', op: 'latest', visitId: 'visit-reading', requestId: 'reading-1', agentId: 'a1' })
await vi.waitFor(() => expect(port.sent.some(x => x.t === 'visit.state')).toBe(true))
expect(visit).toHaveBeenCalledExactlyOnceWith({ op: 'latest', visitId: 'visit-reading', agentId: 'a1' })
expect(port.sent.find(x => x.t === 'visit.state')).toMatchObject({
requestId: 'reading-1', visitId: 'visit-reading', active: true, agentId: 'a1' })
expect(host.openAgent).not.toHaveBeenCalled()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.answer).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('pins selected terminal text at voice start and sends it with the spoken instruction', async () => {
const selectPassage = vi.fn<CableHost['selectPassage'] & {}>(async command => command.op === 'pin'
? { ok: true, selectionId: 'selection-1', revision: 4, rows: 2, extending: true,
excerpt: 'quoted output', text: ' first line\nsecond line' }
: { ok: false, error: 'Selection closed.' })
const host = makeHost({ selectPassage, transcribe: vi.fn(async () => 'Explain this.') })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', uploadId: 'quoted', agentId: 'a2', selectionId: 'selection-1', selectionRevision: 3 })
await settle()
expect(selectPassage).toHaveBeenCalledWith({ op: 'pin', agentId: 'a2', selectionId: 'selection-1', revision: 3 })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.sendTurn).toHaveBeenCalledOnce())
expect(host.sendTurn).toHaveBeenCalledWith('a2', 'Explain this.\n\nContext I selected from this agent\'s terminal:\n> first line\n> second line')
expect(port.sent.find(x => x.t === 'voice.transcript')).toMatchObject({ text: 'Explain this.', uploadId: 'quoted' })
} finally { await session.stop() }
})
it('refuses quoted voice when the selected text changed, without submitting bare words', async () => {
const host = makeHost({ selectPassage: vi.fn(async () => ({ ok: false as const, error: 'That text changed. Choose it again.' })),
transcribe: vi.fn(async () => 'explain this') })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', uploadId: 'changed', agentId: 'a2', selectionId: 'old', selectionRevision: 1 })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.transcribe).not.toHaveBeenCalled()
expect(port.sent.find(x => x.t === 'voice.error')).toMatchObject({ uploadId: 'changed', message: 'That text changed. Choose it again.' })
} finally { await session.stop() }
})
it('discard during a pending text pin cannot send or revive the recording', async () => {
let pin!: (result: import('./windowSelection.js').SelectionResult) => void
const host = makeHost({ selectPassage: command => command.op === 'pin'
? new Promise(resolve => { pin = resolve }) : Promise.resolve({ ok: false, error: 'Closed' }),
transcribe: vi.fn(async () => 'explain') })
const { session, port } = await connect(host)
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', uploadId: 'cancelled-quote', agentId: 'a1', selectionId: 'selected', selectionRevision: 1 })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await settle()
port.say({ t: 'voice.abort', uploadId: 'cancelled-quote' })
pin({ ok: true, selectionId: 'selected', revision: 2, rows: 1, excerpt: 'a', extending: false, text: 'a' })
await settle()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(host.transcribe).not.toHaveBeenCalled()
expect(port.types()).not.toContain('voice.transcript')
} finally { await session.stop() }
})
it('answers hello with welcome and pushes the agent list once', async () => {
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', fw: '0.1.0', proto: 1, mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
expect(port.types()).toEqual(
// Machines FIRST: the dial paints its machine name from this list, so an agent list that lands
// ahead of it shows a nameless placeholder for a frame. Swarms next, for the same reason: the line
// naming the tab is drawn on the tile the agent list is about to build.
//
// `notif.replace` LAST, and that order is the point too: it names agents, and a drawer row for one
// the carousel does not hold yet has nothing to draw itself against. A dial that has just greeted
// us is the one moment its drawer is known to be empty — its rows live in RAM.
['welcome', 'machines.begin', 'machine', 'machines.end', 'swarms', 'agents.begin', 'agent', 'agent', 'agents.end', 'notif.replace'],
)
const welcome = port.sent[0]
expect(welcome).toMatchObject({ t: 'welcome', app: 'harness', machine: { name: 'MacBook Pro' } })
// Streamed one per message: a hundred agents do not fit in one 8 KB frame, and the dial must not have
// to reassemble anything.
expect(port.sent.find((m) => m.t === 'agent')).toMatchObject({ t: 'agent', id: 'a1', name: 'Fix login screen', engine: 'claude' })
await session.stop()
})
it('advertises only the optional device controls this host implements', async () => {
const core = await connect()
core.port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(core.port.types()).toContain('welcome'))
expect(core.port.sent[0].features).toEqual(['voice.draft', 'agents.refresh'])
await core.session.stop()
const advanced = await connect(makeHost({
form: async () => ({ ok: false, active: false, error: 'test' }),
visit: async () => ({ ok: false, active: false, error: 'test' }),
selectPassage: async () => ({ ok: false, error: 'test' }),
answerReviewed: async () => ({ ok: false, error: 'test' }),
}))
advanced.port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(advanced.port.types()).toContain('welcome'))
expect(advanced.port.sent[0].features).toEqual(['voice.draft', 'agents.refresh', 'form', 'selection', 'visit', 'question.review'])
await advanced.session.stop()
})
it('answers a repeat hello WITHOUT re-pushing the list', async () => {
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
// The dial greets every 15 s for as long as it is plugged in. Re-attaching on each of those re-sends
// the whole state four times a minute and undoes the "say nothing when nothing changed" rule.
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
expect(port.types()).toEqual(['welcome'])
await session.stop()
})
it('tells the host the dial is there, on which firmware, and when it leaves — never on a keepalive', async () => {
// What a window draws in its rail. Attach and detach are the two facts; a repeat greeting is
// neither, and a rail that redraws four times a minute to say "still here" is noise.
const seen: unknown[] = []
const { session, port } = await connect(makeHost({ onDialStatus: (status) => seen.push(status) }))
port.say({ t: 'hello', product: 'harness', fw: '0.0.58', proto: 1, mac: 'aa:bb' })
await vi.waitFor(() => expect(seen).toEqual([{ attached: true, fw: '0.0.58' }]))
port.say({ t: 'hello', product: 'harness', fw: '0.0.58', proto: 1, mac: 'aa:bb' })
await settle()
expect(seen).toHaveLength(1)
await port.close('unplugged')
await vi.waitFor(() => expect(seen.at(-1)).toEqual({ attached: false }))
await session.stop()
})
it('re-attaches for a DIFFERENT dial', async () => {
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
port.say({ t: 'hello', product: 'harness', mac: 'cc:dd' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
expect(port.types()).toEqual(
// Machines FIRST: the dial paints its machine name from this list, so an agent list that lands
// ahead of it shows a nameless placeholder for a frame. Swarms next, for the same reason: the line
// naming the tab is drawn on the tile the agent list is about to build.
//
// `notif.replace` LAST, and that order is the point too: it names agents, and a drawer row for one
// the carousel does not hold yet has nothing to draw itself against. A dial that has just greeted
// us is the one moment its drawer is known to be empty — its rows live in RAM.
['welcome', 'machines.begin', 'machine', 'machines.end', 'swarms', 'agents.begin', 'agent', 'agent', 'agents.end', 'notif.replace'],
)
await session.stop()
})
it('names the swarms once and again only when they change, and relays a pick', async () => {
let swarms = { selected: 's1', swarms: [{ id: 's1', name: 'Workshop', agents: 2, panes: 2 }, { id: 's2', name: 'Launch', agents: 0, panes: 0 }], tiles: [] }
const host = makeHost({ listSwarms: () => swarms })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
expect(port.sent.filter((m) => m.t === 'swarms')).toEqual([
{ t: 'swarms', selected: 's1', items: [{ id: 's1', name: 'Workshop', agents: 2, panes: 2 }, { id: 's2', name: 'Launch', agents: 0, panes: 0 }], tiles: [] },
])
// Ticks with nothing new say nothing new — the same rule as the wheel.
await settle()
expect(port.sent.filter((m) => m.t === 'swarms')).toHaveLength(1)
// The dial picks. The host is told and nothing is answered on the wire: the window's new desk is
// the answer, and it arrives as the next swarms/ring push.
port.say({ t: 'swarm.select', swarmId: 's2' })
await settle()
expect(host.selectSwarm).toHaveBeenCalledWith('s2')
swarms = { ...swarms, selected: 's2' }
await vi.waitFor(() => expect(port.sent.filter((m) => m.t === 'swarms')).toHaveLength(2))
expect(port.sent.filter((m) => m.t === 'swarms')[1]).toMatchObject({ selected: 's2' })
await session.stop()
})
it('pushes again when a tab gains a tile but no agent', async () => {
// The change this field exists for: a terminal opened on a tab that holds no agent moves the
// TILE count and nothing else. A diff watching only `agents` swallowed that push and left the
// dial showing a row it still believed was empty — the row it would then refuse to list.
let swarms = { selected: 's1', swarms: [{ id: 's1', name: 'Shell', agents: 0, panes: 0 }], tiles: [] }
const host = makeHost({ listSwarms: () => swarms })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.sent.filter((m) => m.t === 'swarms')).toHaveLength(1))
swarms = { selected: 's1', swarms: [{ id: 's1', name: 'Shell', agents: 0, panes: 1 }], tiles: [] }
await vi.waitFor(() => expect(port.sent.filter((m) => m.t === 'swarms')).toHaveLength(2))
expect(port.sent.filter((m) => m.t === 'swarms')[1]).toMatchObject({
items: [{ id: 's1', agents: 0, panes: 1 }],
})
await session.stop()
})
it('delivers a turn through the host, not through the protocol', async () => {
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'turn.send', agentId: 'a2', text: 'flash it' })
await settle()
expect(host.sendTurn).toHaveBeenCalledWith('a2', 'flash it')
await session.stop()
})
it("refuses a dial that names another product, and lets go of its port", async () => {
// The framing magic should already have made this greeting unreadable — reaching here means something
// this code cannot see has changed (a re-unified magic, a fork of the firmware, a third product). The
// safe reading of "I do not recognise you" is never "you are probably mine".
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'grid', fw: '0.1.2', proto: 1, mac: 'aa:bb' })
await settle()
// No welcome: a welcome is what starts a session, and there is no session to have with someone
// else's dial. And the port is released, because two daemons reading one tty take turns stealing
// each other's bytes — a stalemate that breaks BOTH links, not just this one.
expect(port.types()).not.toContain('welcome')
expect(port.closedWith).toBe('another product')
await session.stop()
})
it('refuses a greeting that names no product at all', async () => {
// Absence has to mean no. Reading it as "probably ours" puts the hole straight back: the firmware
// that predates this field is exactly the firmware the sibling product can still capture.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', fw: '0.0.39', proto: 2, mac: 'aa:bb' })
await settle()
expect(port.types()).not.toContain('welcome')
await session.stop()
})
it('writes a given image to a given dial once, however many sessions it takes', async () => {
// `offered` is per SESSION and every flash ends in a reboot that starts a new one, so it cannot see a
// loop. Two daemons disagreeing about who owns a board flash it every fifteen seconds — 3 MB a time,
// ~700 MB an hour — and the dial pays in erase cycles. This is the memory that outlives the port.
const image = Buffer.alloc(2048, 9)
const host = makeHost({
firmwareFor: async () => ({ version: '9.9.9', image, sha256: 'x'.repeat(64) }),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', fw: '0.0.1', proto: 2, mac: 'aa:bb' })
await settle()
expect(port.types().filter((t) => t === 'fw.offer')).toHaveLength(1)
// A new session for the same dial — what a reboot looks like from here.
port.sent.length = 0
port.say({ t: 'hello', product: 'harness', fw: '0.0.1', proto: 2, mac: 'aa:bb' })
await settle()
expect(port.types()).not.toContain('fw.offer')
await session.stop()
})
it('offers the image to a SECOND dial, even after a first one took it', async () => {
// Found in the field: one dial updated, was unplugged, and a second still on the old image was plugged
// into the same daemon and never offered anything. `offered` held bare version strings, so it was a
// statement about the IMAGE rather than about the board — the second dial was refused because that
// version had been offered to someone else, and nothing in the log said so.
const image = Buffer.alloc(2048, 9)
const host = makeHost({
firmwareFor: async () => ({ version: '9.9.9', image, sha256: 'x'.repeat(64) }),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', fw: '0.0.1', proto: 2, mac: 'aa:bb' })
await settle()
expect(port.types().filter((t) => t === 'fw.offer')).toHaveLength(1)
// The first dial takes it and reboots. Without this the transfer is still in flight and the guard
// that refuses a SECOND concurrent offer would be what answers below — a different rule, and the
// wrong one to be testing here.
port.say({ t: 'fw.done' })
await settle()
// A different board, same daemon, same port — the port does not close when a dial reboots, which is
// why the session's memory of what it has offered outlives the dial it offered to.
port.sent.length = 0
port.say({ t: 'hello', product: 'harness', fw: '0.0.1', proto: 2, mac: 'cc:dd' })
await settle()
expect(port.types().filter((t) => t === 'fw.offer')).toHaveLength(1)
await session.stop()
})
it('does not wedge forever when opening the port hangs', async () => {
// The `opening` guard keeps two opens from racing on one tty — a race that once left five read loops
// shredding a single byte stream. But a guard released only when the attempt FINISHES is a guard held
// forever by an attempt that never does, and the dial is then gone until the daemon is restarted:
// no error, no retry, no line in the log saying why. Seen once in the field — the link closed and
// nothing tried again for twelve minutes, after the dial re-enumerated mid-flash.
vi.useFakeTimers()
try {
let attempts = 0
const session = new CableSession(makeHost(), tmpLog(), async (onData, onClosed) => {
attempts += 1
if (attempts === 1) return new Promise<never>(() => {}) // the hang
return new LoopbackPort(onData, onClosed)
})
session.start()
// Comfortably past OPEN_TIMEOUT_MS plus the reopen tick. It used to advance exactly 20s, which was
// fine while the budget was 8s and became a coin toss the moment the budget itself became 20 —
// the retry lands after the deadline, not on it.
await vi.advanceTimersByTimeAsync(35_000)
expect(attempts).toBeGreaterThan(1)
await session.stop()
} finally {
vi.useRealTimers()
}
})
it('brings the dial to the agent the window opened', async () => {
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
await session.followApp('', 'a2')
await settle()
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toEqual(['a2'])
await session.stop()
})
it('pushes the ring BEFORE the focus that needs it', async () => {
// Reported from the desk: clicking a rail agent that has NO tile yet moves the window and leaves the
// dial where it was — often, not always.
//
// A focus names a tile the dial has to centre, and the dial centres only what its ring WALKS; an
// agent with no tile can be sitting off that ring entirely, where the device drops the focus with
// nothing to move to. The click is what changes the ring — it opens a tile — so the list and the
// focus are one transaction, and the list has to go first.
let agents: CableAgent[] = [{ id: 'a1', name: 'one' }, { id: 'a2', name: 'two' }]
const { session, port } = await connect(makeHost({ listAgents: async () => agents }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
agents = [{ id: 'a2', name: 'two' }, { id: 'a1', name: 'one' }] // the click re-shaped the desk
await session.followApp('', 'a2')
await settle()
const order = port.types().filter((t) => t === 'agents.end' || t === 'focus')
expect(order[0]).toBe('agents.end') // the ring the focus lands on, first
expect(order).toContain('focus')
expect(port.sent.filter((m) => m.t === 'focus').every((m) => m.agentId === 'a2')).toBe(true)
await session.stop()
})
it('hands an open to the host with why the dial sent it — a tap says nothing, a question says so', async () => {
// A question screen that came up on its own opens with reason 'question'; the window then only brings
// the agent forward. A tap carries no reason, and so does anything the daemon does not know.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'agent.open', agentId: 'a2' })
port.say({ t: 'agent.open', agentId: 'a2', reason: 'question' })
port.say({ t: 'agent.open', agentId: 'a2', reason: 'whim' })
await settle()
expect(vi.mocked(host.openAgent).mock.calls).toEqual([['a2', undefined], ['a2', 'question'], ['a2', undefined]])
await session.stop()
})
it("never echoes the dial's own move back at it", async () => {
// THE RING: the dial's carousel reports `focus` up, the daemon hands that to the window, the window
// opens that agent's terminal, and a window opening a terminal is exactly what calls followApp. Left
// unguarded that answers the dial with the move it just made. It settles today only because the far
// end does not re-report a carousel that never moved — a property of its UI, not of this protocol.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'focus', agentId: 'a2' }) // the dial moved itself
await settle()
port.sent.length = 0
await session.followApp('', 'a2') // the window caught up
await settle()
expect(port.sent.filter((m) => m.t === 'focus')).toEqual([])
await session.stop()
})
it('says again where the window is looking after the list is re-sorted', async () => {
// The gap left by making re-anchors silent: the dial rebuilds when a re-sorted list lands, and if it
// lands on the wrong tile nobody notices. Measured — a swipe pulled `a2` into a pane, the window
// showed `a2`, the dial sat on the first agent. The daemon is the only side that knows both, so it
// repeats itself after every push.
let agents: CableAgent[] = [{ id: 'a1', name: 'one' }, { id: 'a2', name: 'two' }]
const { session, port } = await connect(makeHost({ listAgents: async () => agents }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
await session.followApp('', 'a2') // the window is on a2
await settle()
port.sent.length = 0
agents = [{ id: 'a2', name: 'two' }, { id: 'a1', name: 'one' }] // the desk re-sorted the ring
port.say({ t: 'focus', agentId: 'a1' }) // and the dial drifted onto another tile
await settle()
await session.pushAgents()
await settle()
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toEqual(['a2'])
await session.stop()
})
it('a re-push after the window FOLLOWED the dial does not send it back', async () => {
// Reported from the desk as a tile going 3 → 4 → 3 → 4 on its own.
//
// There were two memories of the same fact: where the dial was, and what the window last said. When
// the window follows the DIAL, followApp returns early — the dial is already there — so the window's
// memory kept an OLDER agent, and the next list push re-asserted that stale one. The dial jumped back
// to it, the window's next report pulled it forward, and the two took turns.
let agents: CableAgent[] = [{ id: 'a3', name: 'three' }, { id: 'a4', name: 'four' }]
const { session, port } = await connect(makeHost({ listAgents: async () => agents }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
await session.followApp('', 'a3') // the window led: both screens on a3
await settle()
port.say({ t: 'focus', agentId: 'a4' }) // a hand turns the dial to a4
await settle()
await session.followApp('', 'a4') // the window follows — early return, nothing commanded
await settle()
port.sent.length = 0
agents = [{ id: 'a4', name: 'four' }, { id: 'a3', name: 'three' }] // the desk re-sorts the ring
await session.pushAgents()
await settle()
// The re-assert says a4 — where both screens actually are — and never a3.
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toEqual(['a4'])
await session.stop()
})
it('a swipe made DURING a slow machine switch still reaches the window', async () => {
// Measured on the real desk: selecting a remote machine took ten seconds, and for all ten every dial
// report was discarded as "the window is mid-switch". The dial walked on, the window stayed put, and
// the two screens named different agents with nothing left to correct them.
//
// The repaint this guards against answers the command at once. A hand, seconds later, does not.
let finishSelection!: () => void
const gate = new Promise<void>((resolve) => { finishSelection = resolve })
const focus = vi.fn()
const host = makeHost({
focus,
selectMachine: vi.fn(async () => { await gate; return { ok: true as const } }),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
const following = session.followApp('remote-machine', 'r1')
await settle()
port.say({ t: 'focus', agentId: 'a1' }) // the switch's own repaint: discarded
await settle()
expect(focus).not.toHaveBeenCalled()
vi.setSystemTime(Date.now() + 5_000) // the switch is still going
port.say({ t: 'focus', agentId: 'a2' }) // …and a hand turns the dial
await settle()
expect(focus).toHaveBeenCalledWith('a2')
finishSelection()
await following
vi.useRealTimers()
await session.stop()
})
it('a swipe a second after the window clicked is a SWIPE, not an echo', async () => {
// This window was 4s for one afternoon, to catch a re-anchor the dial should never have reported in
// the first place. It caught real swipes instead: click something in the window, turn the dial within
// four seconds, and the move vanished — the two screens then sat on different agents with nothing to
// correct them, which is exactly the report that followed.
//
// The echo is stopped at its source now: a dial told where to look moves through code, and a move made
// by code is not reported at all. So a report arriving a second later is a hand.
const focus = vi.fn()
const { session, port } = await connect(makeHost({ focus }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
await session.followApp('', 'a2')
await settle()
focus.mockClear()
vi.setSystemTime(Date.now() + 1_000)
port.say({ t: 'focus', agentId: 'a1' })
await settle()
expect(focus).toHaveBeenCalledWith('a1')
vi.useRealTimers()
await session.stop()
})
it('ignores stale dial focus while the app switches to a remote machine', async () => {
let selected = 'mac-local'
let finishSelection!: () => void
const selectionGate = new Promise<void>((resolve) => { finishSelection = resolve })
const host = makeHost({
selectedMachine: () => selected,
selectMachine: vi.fn(async (machineId: string) => {
await selectionGate
selected = machineId
return { ok: true as const }
}),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
const following = session.followApp('remote-machine', 'r1')
await settle()
port.say({ t: 'focus', agentId: 'a1' }) // old tile reported while the list is rebuilding
await settle()
expect(host.focus).not.toHaveBeenCalled()
finishSelection()
await following
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toContain('r1')
port.say({ t: 'focus', agentId: 'a1' }) // stale old tile, delivered just after the switch completed
await settle()
expect(host.focus).not.toHaveBeenCalled()
port.say({ t: 'focus', agentId: 'r1' }) // echo of the app-driven focus
await settle()
expect(host.focus).not.toHaveBeenCalled()
port.say({ t: 'focus', agentId: 'r2' }) // a real subsequent dial move
await settle()
expect(host.focus).toHaveBeenCalledWith('r2')
await session.stop()
})
it('sends the focus without waiting for the new machine\'s history', async () => {
// THE BUG, measured on the desk: clicking from a local agent to a remote one left the dial on the old
// tile for 1.5 s. `selectMachine` awaited `pushRestores`, and a restore asks every agent's own machine
// what it was last doing — one cloud round trip per remote agent, in a serial loop, ahead of the one
// frame the person was actually waiting for. History is `restore: true`; it can land afterwards.
let releaseHistory!: () => void
const history = new Promise<void>((resolve) => { releaseHistory = resolve })
let selected = 'mac-local'
const host = makeHost({
selectedMachine: () => selected,
selectMachine: vi.fn(async (machineId: string) => { selected = machineId; return { ok: true as const } }),
listAgents: async () => [{ id: 'r1', name: 'Remote Claude', engine: 'claude' }],
recentSummaries: async () => { await history; return [{ recap: 'shipped it', text: 'shipped it' }] },
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
await session.followApp('remote-machine', 'r1')
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toContain('r1')
expect(port.types()).not.toContain('summary') // nothing of the history has been waited on
releaseHistory()
await vi.waitFor(() => expect(port.types()).toContain('summary'))
await session.stop()
})
it('does not bounce a local app selection back to the previous remote agent', async () => {
let selected = 'remote-machine'
let finishSelection!: () => void
const selectionGate = new Promise<void>((resolve) => { finishSelection = resolve })
const host = makeHost({
selectedMachine: () => selected,
selectMachine: vi.fn(async (machineId: string) => {
await selectionGate
selected = machineId
return { ok: true as const }
}),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
const following = session.followApp('mac-local', 'a2')
await settle()
port.say({ t: 'focus', agentId: 'r1' })
await settle()
expect(host.focus).not.toHaveBeenCalled()
finishSelection()
await following
port.say({ t: 'focus', agentId: 'r1' }) // late report from the previous remote tile
await settle()
expect(host.focus).not.toHaveBeenCalled()
port.say({ t: 'focus', agentId: 'a2' }) // acknowledgement of the commanded local tile
await settle()
port.say({ t: 'focus', agentId: 'r2' }) // subsequent physical dial move still works
await settle()
expect(host.focus).toHaveBeenCalledTimes(1)
expect(host.focus).toHaveBeenCalledWith('r2')
await session.stop()
})
it('coalesces quick app selections so the newest one focuses last', async () => {
let selected = 'mac-local'
let finishFirstSelection!: () => void
const firstSelectionGate = new Promise<void>((resolve) => { finishFirstSelection = resolve })
let selections = 0
const host = makeHost({
selectedMachine: () => selected,
selectMachine: vi.fn(async (machineId: string) => {
selections += 1
if (selections === 1) await firstSelectionGate
selected = machineId
return { ok: true as const }
}),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
const oldSelection = session.followApp('remote-machine', 'r1')
await settle()
const newestSelection = session.followApp('mac-local', 'a2')
finishFirstSelection()
await Promise.all([oldSelection, newestSelection])
expect(port.sent.filter((m) => m.t === 'focus').map((m) => m.agentId)).toEqual(['a2'])
expect(selected).toBe('mac-local')
await session.stop()
})
it('forwards a whole stroke, including the reports that carry no travel', async () => {
// The ends of a stroke are the point of the message, not padding around it: a `down` with nothing in
// it is what stops a fling still running on the far side, and an `up` with nothing in it is a finger
// that came to rest before it lifted and must land as a stop rather than a throw. A forwarder that
// "optimised away" the empty ones would leave the window holding a drag forever.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'scroll', phase: 'down', dy: 0 })
port.say({ t: 'scroll', phase: 'move', dy: 12 })
port.say({ t: 'scroll', phase: 'up', dy: 3, v: -1800 })
await settle()
expect(host.scrolled).toHaveBeenNthCalledWith(1, 'down', 0, 0)
expect(host.scrolled).toHaveBeenNthCalledWith(2, 'move', 12, 0)
expect(host.scrolled).toHaveBeenNthCalledWith(3, 'up', 3, -1800)
await session.stop()
})
it('drops a stroke whose phase is not one of the three', async () => {
// The dial is the only thing that speaks this today, but the decoder hands up whatever arrives, and a
// phase nobody understands must not reach the window as a drag it can never close.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'scroll', phase: 'sideways', dy: 40 })
await settle()
expect(host.scrolled).not.toHaveBeenCalled()
await session.stop()
})
it('forwards Finder as a distinct surface without focusing or typing into an agent', async () => {
const form = vi.fn(async () => ({ ok: true, active: true, title: 'Find Harness', revision: 1 }))
const host = makeHost({ form }), { session, port } = await connect(host)
try {
port.say({ t: 'form', formId: 'find-one', requestId: 'request-one', op: 'open', surface: 'find' })
await vi.waitFor(() => expect(port.types()).toContain('form.state'))
expect(form).toHaveBeenCalledWith({ formId: 'find-one', op: 'open', surface: 'find', revision: undefined, delta: undefined })
expect(host.focus).not.toHaveBeenCalled(); expect(host.sendTurn).not.toHaveBeenCalled()
expect(port.sent.at(-1)).toMatchObject({ formId: 'find-one', requestId: 'request-one', title: 'Find Harness' })
} finally { await session.stop() }
})
it('uses spoken form text only as a pinned query, never a task', async () => {
const form = vi.fn(async (c: import('./windowForm.js').FormCommand) => ({
ok: true, active: true, canQuery: true, queryId: c.queryId, revision: 2,
}))
const route = vi.fn(), routeInWindow = vi.fn()
const host = makeHost({ form, route, routeInWindow, transcribe: async () => 'Codex.' })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', formId: 'form-one', formRevision: 2, uploadId: 'speech' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.form'))
expect(form.mock.calls.map(([c]) => c.op)).toEqual(['query.begin', 'query', 'query.cancel'])
expect(form).toHaveBeenCalledWith({ op: 'query', formId: 'form-one', revision: 2, queryId: 'speech', text: 'Codex.' })
expect(host.sendTurn).not.toHaveBeenCalled(); expect(route).not.toHaveBeenCalled(); expect(routeInWindow).not.toHaveBeenCalled()
expect(port.sent.at(-1)).toMatchObject({ t: 'voice.form', uploadId: 'speech', formId: 'form-one' })
} finally { await session.stop() }
})
it.each([{ formId: 17 }, { formId: 'form-one', formRevision: 2, agentId: 'a1' },
{ formId: 'form-one', formRevision: -1 }, { formRevision: 2 }])('malformed form voice cannot fall back to routing: %j', async metadata => {
const transcribe = vi.fn(), route = vi.fn(), routeInWindow = vi.fn()
const host = makeHost({ transcribe, route, routeInWindow })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', uploadId: 'speech', ...metadata })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(transcribe).not.toHaveBeenCalled(); expect(host.sendTurn).not.toHaveBeenCalled()
expect(route).not.toHaveBeenCalled(); expect(routeInWindow).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('discard during transcription releases its field and ignores the late words', async () => {
let complete!: (s: string) => void
const transcribe = vi.fn(() => new Promise<string>(resolve => { complete = resolve }))
const form = vi.fn(async (c: import('./windowForm.js').FormCommand) => ({ ok: true, active: true, canQuery: true, queryId: c.queryId }))
const host = makeHost({ form, transcribe })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', formId: 'form-one', formRevision: 2, uploadId: 'speech' })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(transcribe).toHaveBeenCalled())
port.say({ t: 'voice.abort', uploadId: 'speech' }); await settle()
complete('Codex'); await settle()
expect(form.mock.calls.map(([c]) => c.op)).toEqual(['query.begin', 'query.cancel'])
expect(host.sendTurn).not.toHaveBeenCalled(); expect(port.types()).not.toContain('voice.form')
} finally { await session.stop() }
})
it.each(['cancelled', 'abandoned', 'sent'] as const)('ignores a discarded home route that later replies %s', async (outcome) => {
let complete!: (value: { t: 'cancelled' } | { t: 'abandoned' } | { t: 'sent'; agentId: string }) => void
const routeInWindow = vi.fn(() => new Promise<{ t: 'cancelled' } | { t: 'abandoned' } | { t: 'sent'; agentId: string }>((resolve) => { complete = resolve }))
const host = makeHost({ routeInWindow })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', uploadId: 'old' })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(routeInWindow).toHaveBeenCalled())
port.say({ t: 'voice.abort', uploadId: 'old' })
port.say({ t: 'voice.begin', uploadId: 'new', agentId: 'a1' })
port.pcm(Buffer.alloc(3200))
// This can arrive after the replacement starts: it still belongs to the old turn.
port.say({ t: 'voice.abort', uploadId: 'old' })
complete(outcome === 'sent' ? { t: 'sent', agentId: 'a2' } : { t: outcome })
await settle()
expect(port.sent.filter((m) => String(m.t).startsWith('voice.'))).toEqual([])
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.sendTurn).toHaveBeenCalledWith('a1', 'fix the login screen'))
expect(host.sendTurn).toHaveBeenCalledTimes(1)
expect(port.sent.filter((m) => String(m.t).startsWith('voice.'))).toEqual([
expect.objectContaining({ t: 'voice.transcript', uploadId: 'new', agentId: 'a1' }),
])
} finally { await session.stop() }
})
it('does not route or submit a recording discarded during transcription', async () => {
let complete!: (text: string) => void
const transcribe = vi.fn(() => new Promise<string>((resolve) => { complete = resolve }))
const routeInWindow = vi.fn()
const host = makeHost({ transcribe, routeInWindow })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', uploadId: 'discarded' })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(transcribe).toHaveBeenCalled())
port.say({ t: 'voice.abort', uploadId: 'discarded' })
complete('discard this instruction')
await settle()
expect(routeInWindow).not.toHaveBeenCalled()
expect(host.sendTurn).not.toHaveBeenCalled()
expect(port.sent.filter((m) => String(m.t).startsWith('voice.'))).toEqual([])
} finally { await session.stop() }
})
it('tags transcription errors with the recording id', async () => {
const host = makeHost({ transcribe: async () => { throw new Error('retry voice') } })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', uploadId: 'failed' })
port.pcm(Buffer.alloc(3200))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.sent).toContainEqual({ t: 'voice.error', uploadId: 'failed', message: 'retry voice' }))
} finally { await session.stop() }
})
it('routes a voice turn that names no agent', async () => {
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
port.say({ t: 'voice.begin', lang: 'vi' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.transcript'))
// The transcript went to the router's pick, and the dial was told which tile to land on.
expect(host.sendTurn).toHaveBeenCalledWith('a1', 'fix the login screen')
expect(port.sent.at(-1)).toMatchObject({ t: 'voice.transcript', agentId: 'a1', agentName: 'Fix login screen' })
await session.stop()
})
it('lets the window route a spoken task, and does NOT send it a second time', async () => {
// The window DELIVERS what its palette picks. Sending here as well is one sentence arriving twice.
const route = vi.fn()
const routeInWindow = vi.fn(async () => ({ t: 'sent' as const, agentId: 'a2' }))
const host = makeHost({ route, routeInWindow })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
port.say({ t: 'voice.begin', lang: 'en' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.transcript'))
expect(routeInWindow).toHaveBeenCalledWith('fix the login screen', undefined)
expect(host.sendTurn).not.toHaveBeenCalled()
expect(route).not.toHaveBeenCalled()
// The dial is still told where the words went, so it lands on that tile and drops the overlay.
expect(port.sent.at(-1)).toMatchObject({ t: 'voice.transcript', agentId: 'a2', agentName: 'Device firmware voice' })
await session.stop()
})
it('carries the command word to the window, so /goal stays a goal', async () => {
const routeInWindow = vi.fn(async () => ({ t: 'sent' as const, agentId: 'a1' }))
const host = makeHost({ routeInWindow })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', lang: 'en', cmd: 'goal' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(routeInWindow).toHaveBeenCalled())
expect(routeInWindow).toHaveBeenCalledWith('fix the login screen', 'goal')
await session.stop()
})
it('sends nothing when a person closes the palette, and says so', async () => {
const route = vi.fn()
const host = makeHost({ route, routeInWindow: async () => ({ t: 'cancelled' as const }) })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
port.say({ t: 'voice.begin', lang: 'en' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
// Cancelling must not fall through to routing here — that would deliver what the person just refused.
expect(host.sendTurn).not.toHaveBeenCalled()
expect(route).not.toHaveBeenCalled()
await session.stop()
})
it('will not race a pick that is still coming when the window goes quiet', async () => {
const route = vi.fn()
const host = makeHost({ route, routeInWindow: async () => ({ t: 'abandoned' as const }) })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
port.say({ t: 'voice.begin', lang: 'en' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(host.sendTurn).not.toHaveBeenCalled()
expect(route).not.toHaveBeenCalled()
await session.stop()
})
it('routes here when no window is listening', async () => {
const host = makeHost({ routeInWindow: async () => ({ t: 'unavailable' as const }) })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', lang: 'en' })
port.pcm(Buffer.alloc(3200, 7))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.sendTurn).toHaveBeenCalled())
// The dial keeps working with the window shut — this is the whole reason the old router stays.
expect(host.sendTurn).toHaveBeenCalledWith('a1', 'fix the login screen')
await session.stop()
})
it('sends a voice turn straight to the agent the dial named', async () => {
const route = vi.fn()
const host = makeHost({ route })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', agentId: 'a2', lang: 'en' })
port.pcm(Buffer.alloc(3200, 1))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(host.sendTurn).toHaveBeenCalled())
// Naming a tile IS the decision. Asking a router to confirm it is latency spent to reach the same answer.
expect(route).not.toHaveBeenCalled()
expect(host.sendTurn).toHaveBeenCalledWith('a2', 'fix the login screen')
await session.stop()
})
it('transcribes at the rate the dial states, not a guess', async () => {
// 8 kHz described as 16 kHz is not slightly-off speech: the container lies about itself and the
// transcriber answers with an empty string.
let sawRate = 0
const host = makeHost({
transcribe: async (_pcm, rate) => { sawRate = rate; return 'xin chào' },
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', lang: 'vi', sr: 8000 })
port.pcm(Buffer.alloc(1600, 3))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(sawRate).toBe(8000))
await session.stop()
})
it('says so rather than guessing when transcription fails', async () => {
const host = makeHost({
transcribe: async () => {
throw new Error('Voice needs CABLE_STT_API_KEY')
},
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'voice.begin', lang: 'en' })
port.pcm(Buffer.alloc(64))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(port.sent.at(-1)).toMatchObject({ t: 'voice.error', message: 'Voice needs CABLE_STT_API_KEY' })
expect(host.sendTurn).not.toHaveBeenCalled()
await session.stop()
})
it('corrects a list it sent before the registry was ready', async () => {
// THE BUG THIS EXISTS FOR: a daemon that has just restarted answers `hello` before its registry has
// finished loading, so the honest answer at that instant is "no agents" — and under a rule that only
// speaks when something changed, that one answer stood forever. The dial removed every tile and sat
// empty while the daemon knew about two.
let agents: CableAgent[] = []
const { session, port } = await connect(makeHost({ listAgents: async () => agents }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
expect(port.sent.filter((m) => m.t === 'agent')).toHaveLength(0)
port.sent.length = 0
agents = AGENTS // the registry finishes loading
await vi.waitFor(() => expect(port.types()).toContain('agents.end'), { timeout: 3000 })
expect(port.sent.filter((m) => m.t === 'agent')).toHaveLength(2)
await session.stop()
})
it('stays quiet while the list is unchanged', async () => {
// The other half of the same rule. A push per tick would re-send the whole board every second and
// undo the thing that keeps an idle link idle.
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
await new Promise((r) => setTimeout(r, 2500)) // two ticks
expect(port.types().filter((t) => t.startsWith('agents'))).toEqual([])
await session.stop()
})
it('sends the account-wide count and the tab beside the list, not the rows behind them', async () => {
// The dial draws one number on the overview; the seventy rows behind it stay here. And `tab` is what
// tells a shut window from an empty tab when both send zero agents.
const { session, port } = await connect(makeHost({ agentTotal: () => 71, activeSwarm: () => 'tab-9' }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await session.pushAgents()
expect(port.sent.filter((m) => m.t === 'agents.end').at(-1)).toMatchObject({ total: 71, tab: 'tab-9' })
expect(port.sent.filter((m) => m.t === 'agents.end').at(-1)).not.toHaveProperty('ring')
await session.stop()
})
it('pushes the same zero rows again when the window shuts, and not when only the tab changes', async () => {
// Empty tab → shut app is a change the dial draws ("Nothing on this tab" → "Run OpenHarness"); empty
// tab A → empty tab B is not — the `swarms` frame carries the tab's name — and pushing on it made
// every tab switch cost two list pushes.
let tab = 'a'
const { session, port } = await connect(makeHost({ listAgents: async () => [], activeSwarm: () => tab }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
tab = 'b'
await session.syncAgents()
expect(port.types().filter((t) => t === 'agents.end')).toEqual([])
tab = ''
await session.syncAgents()
expect(port.sent.filter((m) => m.t === 'agents.end').at(-1)).toMatchObject({ tab: '' })
await session.stop()
})
it('keeps streamed pane rows and their workspace metadata in the same snapshot', async () => {
let tab = 'old-tab', total = 2
const { session, port } = await connect(makeHost({ activeSwarm: () => tab, agentTotal: () => total }))
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('notif.replace'))
port.sent.length = 0
const write = port.write.bind(port)
port.write = async bytes => {
await write(bytes)
if (port.sent.at(-1)?.t === 'agent') { tab = 'new-tab'; total = 99 }
}
await session.syncAgents(true)
expect(port.sent.find(m => m.t === 'agents.end')).toMatchObject({ tab: 'old-tab', total: 2 })
expect(port.sent.filter(m => m.t === 'agent').map(m => m.id)).toEqual(['a1', 'a2'])
} finally { await session.stop() }
})
it.each(['agents.refresh', 'agents.list'])('acknowledges an empty-workspace refresh via %s', async command => {
let tab = 'empty-a'
const { session, port } = await connect(makeHost({ activeSwarm: () => tab, listAgents: async () => [] }))
try {
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('notif.replace'))
port.sent.length = 0
tab = 'empty-b'; await session.syncAgents()
expect(port.sent).toEqual([]) // Unchanged rows are normally deduplicated.
port.say({ t: command })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
if (command === 'agents.refresh') expect(port.types()).toEqual(['agents.begin', 'agents.end'])
else expect(port.types()).toContain('swarms') // Legacy full state push is still a valid receipt.
expect(port.sent.find(m => m.t === 'agents.end')).toMatchObject({ total: 2, tab: 'empty-b' })
expect(port.types()).not.toContain('voice.transcript')
} finally { await session.stop() }
})
it('forks on the dial\'s agent.fork and toasts only a refusal', async () => {
const forkAgent = vi.fn(async (id: string) => id === 'a1'
? { ok: true as const, agentId: 'a1-fork' }
: { ok: false as const, error: 'AGENT_BUSY', detail: 'Wait for it to finish, then fork.' })
const { session, port } = await connect(makeHost({ forkAgent }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
port.say({ t: 'agent.fork', agentId: 'a1' })
await vi.waitFor(() => expect(forkAgent).toHaveBeenCalledWith('a1'))
await new Promise((r) => setTimeout(r, 20))
// The window is told through the host (`opened`); the dial hears nothing on success.
expect(port.types().filter((t) => t === 'toast')).toEqual([])
port.say({ t: 'agent.fork', agentId: 'a2' })
await vi.waitFor(() => expect(port.sent.find((m) => m.t === 'toast')).toMatchObject({ text: 'Wait for it to finish, then fork.' }))
await session.stop()
})
it('names the agent on every summary and question, for one the dial does not hold', async () => {
const { session, port } = await connect(makeHost({}))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await session.summary('a2', 'recap', 'body')
await session.question('a1', 'q1', [{ key: 'k' }])
// An id this daemon never listed: the fields travel empty rather than the frame being withheld.
await session.summary('ghost', 'recap', 'body')
expect(port.sent.find((m) => m.t === 'summary' && m.agentId === 'a2'))
.toMatchObject({ name: 'Device firmware voice', engine: 'codex', machine: '' })
expect(port.sent.find((m) => m.t === 'question')).toMatchObject({ name: 'Fix login screen', engine: 'claude', id: 'q1' })
expect(port.sent.find((m) => m.t === 'summary' && m.agentId === 'ghost')).toMatchObject({ name: '', engine: '' })
await session.stop()
})
it('marks a summary quiet when the window already has that agent on screen', async () => {
const { session, port } = await connect(makeHost({}))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await session.summary('a1', 'recap one', 'body one')
await session.summary('a2', 'recap two', 'body two', true)
const sent = port.sent.filter((m) => m.t === 'summary' && !m.restore)
// Absent, not false, on the ordinary path: firmware that predates the flag must keep notifying
// exactly as it did, and it reads a missing field as false.
expect(sent.find((m) => m.agentId === 'a1')).not.toHaveProperty('quiet')
// The recap still travels — the tile draws it either way. Only the beep and the drawer are withheld.
expect(sent.find((m) => m.agentId === 'a2')).toMatchObject({ quiet: true, recap: 'recap two' })
// A sub-agent's turn: silent — no beep, no drawer row — and the recap still travels.
await session.summary('a1', 'recap three', 'body three', false, true)
const silent = port.sent.filter((m) => m.t === 'summary' && m.agentId === 'a1').pop()
expect(silent).toMatchObject({ silent: true, recap: 'recap three' })
expect(silent).not.toHaveProperty('quiet')
})
it('redraws a reattached dial with what each agent was last doing', async () => {
// The summaries were on disk the whole time; a tile with a name and no recap has forgotten the work
// it belongs to. Newest LAST on the wire so it ends up on top of the tile's stack.
const host = makeHost({
recentSummaries: async (id) =>
id === 'a1' ? [{ recap: 'newest', text: 'b2' }, { recap: 'older', text: 'b1' }] : [],
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types().filter((t) => t === 'summary')).toHaveLength(2))
const restores = port.sent.filter((m) => m.t === 'summary')
expect(restores.map((m) => m.recap)).toEqual(['older', 'newest'])
// Every one is marked history. Without this, plugging the cable in announces every turn that
// finished while it was unplugged — a beep and a notification each.
expect(restores.every((m) => m.restore === true)).toBe(true)
await session.stop()
})
it('answers models.list even when the catalog is empty', async () => {
// The dial BLOCKS on this one message — it cannot draw a picker until the catalog is in hand. Silence
// strands it on a spinner until its own timeout, which reads as a hang rather than "no choices here".
const { session, port } = await connect(makeHost({ listModels: async () => [] }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.sent.length = 0
port.say({ t: 'models.list', agentId: 'a1', mode: 'model' })
await vi.waitFor(() => expect(port.types()).toContain('models'))
expect(port.sent.at(-1)).toMatchObject({ t: 'models', agentId: 'a1', items: [] })
await session.stop()
})
it('answers models.list when the provider throws', async () => {
const { session, port } = await connect(makeHost({ listModels: async () => { throw new Error('no engine') } }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'models.list', agentId: 'a1' })
await vi.waitFor(() => expect(port.types()).toContain('models'))
await session.stop()
})
it('echoes bounded model request serials and preserves legacy replies', async () => {
const { session, port } = await connect(makeHost({ listModels: async () => ['test-model'] }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
for (const request of [1, 0x7fffffff, undefined, 0, -1, 1.5, 0x80000000, '12', { value: 1 }]) {
port.sent.length = 0
port.say({ t: 'models.list', agentId: 'a1', request })
await vi.waitFor(() => expect(port.types()).toContain('models'))
const reply = port.sent.find((m) => m.t === 'models')!
expect(reply).toMatchObject({ agentId: 'a1', items: [{ id: 'test-model' }] })
if (request === 1 || request === 0x7fffffff) expect(reply.request).toBe(request)
else expect(reply).not.toHaveProperty('request')
}
await session.stop()
})
it('names the board a dial greets with, and lives without it', async () => {
// Two dials ship on one image; the firmware says which it is in `hw`. A firmware from before the
// field greets without it, and that is not an error — only a shorter line.
const lines: string[] = []
const seen: unknown[] = []
const { session, port } = await connect(makeHost({ log: (l) => lines.push(l), onDialStatus: (s) => seen.push(s) }))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb', fw: '0.0.68', proto: 3, hw: 'cst816s' })
await vi.waitFor(() => expect(lines.some((l) => l.includes('on fw 0.0.68 proto 3 hw cst816s'))).toBe(true))
expect(seen).toEqual([{ attached: true, fw: '0.0.68', hw: 'cst816s' }])
port.say({ t: 'hello', product: 'harness', mac: 'cc:dd', fw: '0.0.67', proto: 3 })
await vi.waitFor(() => expect(lines.some((l) => l.includes('dial cc:dd on fw 0.0.67 proto 3'))).toBe(true))
expect(seen.at(-1)).toEqual({ attached: true, fw: '0.0.67' })
await session.stop()
})
it('files framed device logs instead of losing them', async () => {
// The dial has ONE USB port, shared by its console and this protocol. While the daemon holds it,
// these frames are the only copy of that console which exists anywhere — `idf.py monitor` cannot open
// the port at the same time.
const log = tmpLog()
const { session, port } = await connect(makeHost(), log)
port.logLine('I (1234) cable: link up')
await vi.waitFor(() => expect(readFileSync(log.currentPath, 'utf8')).toContain('I (1234) cable: link up'))
// The daemon's side of the story lands in the SAME file, marked as the daemon's.
expect(readFileSync(log.currentPath, 'utf8')).toMatch(/\[daemon\] open on /)
await session.stop()
})
it('drops audio that arrives outside a turn', async () => {
// A dial that rebooted mid-capture starts sending PCM again with no `voice.begin` in front of it.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.pcm(Buffer.alloc(3200, 9))
port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(host.sendTurn).not.toHaveBeenCalled()
await session.stop()
})
// ── the machine wheel ─────────────────────────────────────────────────────────────────────────────
it('streams the machine list and names the selected one', async () => {
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('machines.end'))
expect(port.sent.find((m) => m.t === 'machine')).toMatchObject({
t: 'machine', id: 'mac-local', name: 'MacBook Pro', state: 'ready', local: true,
})
expect(port.sent.find((m) => m.t === 'machines.end')).toMatchObject({ selected: 'mac-local', source: 'backend' })
await session.stop()
})
it('names the CABLED computer in welcome, not the dial', async () => {
// Until proto 2 this carried the dial's own MAC as the machine id — a value nothing could ever select.
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('welcome'))
expect(port.sent[0]).toMatchObject({ t: 'welcome', machine: { id: 'mac-local' }, selected: 'mac-local' })
await session.stop()
})
it('says nothing about machines when nothing changed', async () => {
// The 15s hello cadence and every port reopen would otherwise re-push a list the dial already has —
// the flap that measured out at 31 session restarts an hour, with the agent list wiped each time.
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('machines.end'))
const before = port.types().filter((t) => t === 'machines.begin').length
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }) // same dial, same firmware: a keepalive
await settle()
expect(port.types().filter((t) => t === 'machines.begin')).toHaveLength(before)
await session.stop()
})
it('acks a select, then pushes THAT machine\'s agents', async () => {
const other: CableMachine = { id: 'm2', name: 'office-imac', state: 'ready', local: false }
const remote: CableAgent[] = [{ id: 'r1', name: 'api refactor', engine: 'codex' }]
let selected = 'mac-local'
const host = makeHost({
listMachines: async () => ({ machines: [LOCAL_ROW, other], source: 'backend' as const }),
selectedMachine: () => selected,
selectMachine: async (id: string) => { selected = id; return { ok: true as const } },
listAgents: async () => (selected === 'm2' ? remote : AGENTS),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
port.say({ t: 'machine.select', machineId: 'm2' })
await vi.waitFor(() => expect(port.types()).toContain('machine.selected'))
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
expect(port.sent.filter((m) => m.t === 'agent')).toHaveLength(1)
expect(port.sent.find((m) => m.t === 'agent')).toMatchObject({ id: 'r1' })
await session.stop()
})
it('re-sends neither the list nor the history when a select changes neither', async () => {
// The carousel spans every machine: `listAgentsFlat` reads the same agents whichever row wears the ✓,
// so moving the ✓ changes the machine wheel and nothing else. This used to force both pushes anyway —
// 55 frames of a list and a history the dial already had, down a cable the `focus` the person just
// clicked has to share. Measured on the desk: the focus was written in 20 ms and landed 1.7 s later.
const everywhere: CableAgent[] = [...AGENTS]
let selected = 'mac-local'
const host = makeHost({
listMachines: async () => ({
machines: [LOCAL_ROW, { id: 'm2', name: 'twin', state: 'ready' as const, local: false }],
source: 'backend' as const,
}),
selectedMachine: () => selected,
selectMachine: async (id: string) => { selected = id; return { ok: true as const } },
listAgents: async () => everywhere,
recentSummaries: async () => [{ recap: 'shipped it', text: 'shipped it' }],
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
await vi.waitFor(() => expect(port.types()).toContain('summary')) // the attach DOES restore
port.sent.length = 0
port.say({ t: 'machine.select', machineId: 'm2' })
await vi.waitFor(() => expect(port.types()).toContain('machine.selected'))
await settle()
expect(port.types()).toContain('machines.end') // the ✓ moved, so the wheel is re-sent
expect(port.types()).not.toContain('agent') // the tiles did not
expect(port.types()).not.toContain('summary') // nor the history behind them
await session.stop()
})
it('restores a tile that appears after the attach, and only that one', async () => {
// A remote machine's agents reach the cache seconds after the greeting, so the attach's restore ran
// before they existed. They must still arrive with their history — without dragging every tile that
// already has one back down the cable behind them.
let agents: CableAgent[] = [{ id: 'a1', name: 'one' }]
const asked: string[] = []
const { session, port } = await connect(makeHost({
listAgents: async () => agents,
recentSummaries: async (agentId: string) => { asked.push(agentId); return [{ recap: 'shipped it', text: '' }] },
}))
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('summary'))
port.sent.length = 0
asked.length = 0
agents = [...agents, { id: 'r1', name: 'the remote one' }] // that machine's list just landed
await session.syncAgents()
await vi.waitFor(() => expect(port.types()).toContain('summary'))
expect(asked).toEqual(['r1'])
expect(port.sent.filter((m) => m.t === 'summary').map((m) => m.agentId)).toEqual(['r1'])
await session.stop()
})
it('reports a refused select and leaves the selection alone', async () => {
const host = makeHost({
selectMachine: async () => ({ ok: false as const, code: 'NEEDS_LINK', message: 'Run harness link import' }),
})
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
port.say({ t: 'machine.select', machineId: 'm2' })
await vi.waitFor(() => expect(port.types()).toContain('machine.error'))
expect(port.sent.find((m) => m.t === 'machine.error')).toMatchObject({
machineId: 'm2', code: 'NEEDS_LINK', message: 'Run harness link import',
})
expect(port.types()).not.toContain('agents.begin') // the old machine's tiles stay put
await session.stop()
})
it('answers a select of the machine already selected instead of going quiet', async () => {
// Silence here strands the dial on its spinner until its own deadline, which reads as a hang.
const { session, port } = await connect()
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('agents.end'))
port.sent.length = 0
port.say({ t: 'machine.select', machineId: 'mac-local' })
await vi.waitFor(() => expect(port.types()).toContain('machine.selected'))
await session.stop()
})
it('shows one row and says why when there is no lane to anywhere else', async () => {
const host = makeHost({ listMachines: async () => ({ machines: [LOCAL_ROW], source: 'signed-out' as const }) })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.types()).toContain('machines.end'))
expect(port.sent.filter((m) => m.t === 'machine')).toHaveLength(1)
expect(port.sent.find((m) => m.t === 'machines.end')).toMatchObject({ source: 'signed-out' })
await session.stop()
})
it('forwards a question answer verbatim, keyed by the QUESTION keys', async () => {
// The dial has always sent {agentId, requestId, answers}; this case used to demand {id, optionId} and
// silently dropped every answer the question screen produced.
const host = makeHost()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
port.say({ t: 'answer', agentId: 'a1', requestId: 'req-7', answers: { scope: 'wide', mode: 'plan' } })
await vi.waitFor(() => expect(host.answer).toHaveBeenCalled())
expect(host.answer).toHaveBeenCalledWith('a1', 'req-7', { scope: 'wide', mode: 'plan' })
await session.stop()
})
it('fits ten machines with long names into single frames', async () => {
const many: CableMachine[] = Array.from({ length: 10 }, (_, i) => ({
id: `machine-${String(i).padStart(30, 'x')}`,
name: 'x'.repeat(39),
state: 'ready' as const,
local: i === 0,
}))
const host = makeHost({ listMachines: async () => ({ machines: many, source: 'backend' as const }) })
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(port.sent.filter((m) => m.t === 'machine')).toHaveLength(10))
for (const frame of port.frames) expect(frame.length).toBeLessThan(8192)
await session.stop()
})
it('never interleaves two streamed pushes', async () => {
// MEASURED ON HARDWARE, 2026-08-25, first plug-in of the proto-2 firmware: the dial reported EIGHT
// machines while the daemon's own log said it had sent four, three times.
//
// `onMessage` is fired from the decoder and never awaited, so a dial that greets and then asks for
// both lists has three pushes in flight at once — and each row parks on an await. Unserialised, the
// wire carried `begin begin begin row×6 end end end`: the dial reset its staging on every begin,
// accumulated every push's rows, and committed the pile on the first end.
//
// THE SLOW PORT IS THE TEST. With a write that resolves synchronously the three pushes run to
// completion one at a time and this passes with or without the fix — which is exactly what the first
// version of this test did, and why it proved nothing. A real tty accepts a few hundred bytes at a
// time; every frame parks.
class SlowPort implements CablePort {
readonly path = 'slow'
isOpen = true
readonly sent: Array<Record<string, unknown>> = []
private decoder = new CableDecoder()
constructor(private readonly onData: (chunk: Buffer) => void) {}
async write(bytes: Uint8Array): Promise<void> {
await new Promise((r) => setTimeout(r, 0))
this.decoder.feed(Buffer.from(bytes), (frame) => {
if (frame.type === CableType.Json) this.sent.push(JSON.parse(Buffer.from(frame.payload).toString('utf8')))
})
}
async close(): Promise<void> { this.isOpen = false }
say(msg: Record<string, unknown>): void {
this.onData(Buffer.from(encodeCableFrame(CableType.Json, Buffer.from(JSON.stringify(msg), 'utf8'))))
}
}
const rows: CableMachine[] = [
LOCAL_ROW,
{ id: 'm2', name: 'office-imac', state: 'ready', local: false },
]
const host = makeHost({ listMachines: async () => ({ machines: rows, source: 'backend' as const }) })
let port!: SlowPort
const session = new CableSession(host, tmpLog(), async (onData) => {
port = new SlowPort(onData)
return port
})
session.start()
await vi.waitFor(() => expect(port).toBeTruthy())
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
port.say({ t: 'machines.list' })
port.say({ t: 'agents.list' })
await vi.waitFor(() => {
expect(port.sent.filter((m) => m.t === 'machines.end').length).toBeGreaterThanOrEqual(3)
expect(port.sent.filter((m) => m.t === 'agents.end').length).toBeGreaterThanOrEqual(2)
expect(port.sent.filter((m) => m.t === 'notif.replace').length).toBeGreaterThanOrEqual(2)
})
// Walk the wire: a `begin` may never open inside another, and each pair must hold the rows of ONE list.
let open = ''
let seen = 0
for (const m of port.sent) {
if (m.t === 'machines.begin' || m.t === 'agents.begin') {
expect(open, 'a begin arrived inside another begin').toBe('')
open = m.t === 'machines.begin' ? 'machine' : 'agent'; seen = 0
} else if (m.t === 'machine' || m.t === 'agent') {
expect(open, 'a row arrived outside its own begin/end pair').toBe(m.t); seen++
} else if (m.t === 'machines.end' || m.t === 'agents.end') {
expect(open).toBe(m.t === 'machines.end' ? 'machine' : 'agent')
expect(seen).toBe(open === 'machine' ? rows.length : AGENTS.length); open = ''
}
}
expect(open, 'a begin was never closed').toBe('')
await session.stop()
})
it('tells the host when the dial arrives and when it goes', async () => {
// The daemon's cloud lane is held on the dial's behalf, so these two are what open and close it. A
// port that goes silent must look identical to a cable that was pulled — both are "no dial".
const host = makeHost()
host.onDialAttached = vi.fn()
host.onDialGone = vi.fn()
const { session, port } = await connect(host)
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await vi.waitFor(() => expect(host.onDialAttached).toHaveBeenCalledTimes(1))
// A keepalive greeting from the same dial is not a new arrival — re-opening the lane on every one
// would dial the cloud every fifteen seconds for as long as the dial sits there.
port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' })
await settle()
expect(host.onDialAttached).toHaveBeenCalledTimes(1)
await session.stop()
expect(host.onDialGone).toHaveBeenCalled()
})
})
describe('speak an answer, then review', () => {
const qs = [{ key: 'scope', q: 'Which scope?', options: ['File', 'Project'], multi: false, canText: true }]
async function prepare(over: Partial<CableHost> = {}) {
const answerReviewed = vi.fn<NonNullable<CableHost['answerReviewed']>>(async () => ({ ok: true }))
const host = makeHost({ answerReviewed, canSpeakQuestion: () => true, transcribe: async () => 'Only the parser, please.', ...over })
const c = await connect(host)
c.port.say({ t: 'hello', product: 'harness', mac: 'aa:bb' }); await settle()
await c.session.question('a1', 'q-speak', qs)
c.port.say({ t: 'question.read', agentId: 'a1', requestId: 'read' })
await vi.waitFor(() => expect(c.port.types()).toContain('question.state'))
const token = c.port.sent.find(m => m.t === 'question.state')!.token
return { ...c, token, answerReviewed }
}
it('returns recognized words for review with no agent input, then delivers the exact approved draft once', async () => {
const { session, port, host, token, answerReviewed } = await prepare()
try {
port.say({ t: 'voice.begin', agentId: 'a1', questionToken: token, questionIndex: 0, uploadId: 'speech-q' })
port.pcm(Buffer.alloc(640)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.question'))
const draft = port.sent.find(m => m.t === 'voice.question')!
expect(draft).toMatchObject({ agentId: 'a1', token, questionIndex: 0, text: 'Only the parser, please.', uploadId: 'speech-q' })
expect(answerReviewed).not.toHaveBeenCalled(); expect(host.sendTurn).not.toHaveBeenCalled()
const submit = { t: 'answer.reviewed', agentId: 'a1', requestId: 'send', token, choices: [0], drafts: [draft.draftId] }
port.say(submit); port.say(submit)
await vi.waitFor(() => expect(answerReviewed).toHaveBeenCalledTimes(1))
expect(answerReviewed.mock.calls[0][0]).toMatchObject({ freeTextKeys: ['scope'], answers: { scope: 'Only the parser, please.' } })
} finally { await session.stop() }
})
it.each(['discard', 'close', 'replace', 'disconnect'] as const)('drops a late transcript after %s', async reason => {
let resolve!: (s: string) => void
const transcribe = vi.fn(() => new Promise<string>(r => { resolve = r }))
const { session, port, host, token, answerReviewed } = await prepare({ transcribe })
try {
port.say({ t: 'voice.begin', agentId: 'a1', questionToken: token, questionIndex: 0, uploadId: 'speech-q' })
port.pcm(Buffer.alloc(640)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(transcribe).toHaveBeenCalled())
if (reason === 'discard') port.say({ t: 'voice.abort', uploadId: 'speech-q' })
if (reason === 'close') await session.questionClose('a1', 'q-speak')
if (reason === 'replace') await session.question('a1', 'q-new', qs)
if (reason === 'disconnect') await port.close()
resolve('This must not send'); await settle(); await settle()
expect(port.types()).not.toContain('voice.question')
expect(answerReviewed).not.toHaveBeenCalled(); expect(host.sendTurn).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it.each([{ questionIndex: 0 }, { questionToken: 'wrong', questionIndex: 0 },
{ questionToken: 'bad', questionIndex: -1 }, { questionToken: null }])('never routes malformed question voice: %j', async metadata => {
const transcribe = vi.fn(async () => 'wrong'), route = vi.fn()
const { session, port, host, answerReviewed } = await prepare({ transcribe, route })
try {
port.say({ t: 'voice.begin', agentId: 'a1', ...metadata, uploadId: 'speech-q' })
port.pcm(Buffer.alloc(640)); port.say({ t: 'voice.end' })
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(transcribe).not.toHaveBeenCalled(); expect(route).not.toHaveBeenCalled()
expect(answerReviewed).not.toHaveBeenCalled(); expect(host.sendTurn).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('does not offer speech without a capable local receiver', async () => {
const { session, port } = await prepare({ canSpeakQuestion: () => false })
try {
const state = port.sent.find(m => m.t === 'question.state')!
expect(state.questions).toMatchObject([{ canText: false }])
} finally { await session.stop() }
})
})
describe('task voice draft over the cable', () => {
async function recorded(port: LoopbackPort, fields: Record<string, unknown> = {}, review = true) {
const uploadId = `draft-${port.sent.length}`
port.say({ t: 'voice.begin', uploadId, ...fields }); port.pcm(Buffer.alloc(320))
port.say({ t: 'voice.end', uploadId, review })
await vi.waitFor(() => expect(port.sent.some(m => m.uploadId === uploadId && ['voice.draft','voice.error','voice.transcript'].includes(m.t as string))).toBe(true))
return port.sent.find(m => m.uploadId === uploadId && m.t !== 'voice.quota')!
}
async function command(port: LoopbackPort, page: Record<string,unknown>, op: string, fields:Record<string,unknown>={}) {
const requestId=`draft-cmd-${port.sent.length}`
port.say({ t:'draft.command', draftId:page.id, revision:page.revision, requestId, op, ...fields })
await vi.waitFor(()=>expect(port.sent.some(m=>m.requestId===requestId)).toBe(true))
return port.sent.find(m=>m.requestId===requestId)!
}
it('reviews, replaces a section, appends, undoes, and sends once to the original recipient', async () => {
const transcribe=vi.fn().mockResolvedValueOnce('First idea.').mockResolvedValueOnce('Better idea.').mockResolvedValueOnce('Keep the tests.')
const host=makeHost({transcribe}), {session,port}=await connect(host)
try {
let p=await recorded(port,{agentId:'a2'})
expect(p).toMatchObject({ t:'voice.draft', agentId:'a2', text:'First idea.', active:true })
expect(host.sendTurn).not.toHaveBeenCalled()
p=await recorded(port,{draftId:p.id,draftRevision:p.revision,draftOp:'replace'},false)
expect(p.text).toBe('Better idea.')
p=await recorded(port,{draftId:p.id,draftRevision:p.revision,draftOp:'append'},false)
expect(p.text).toBe('Better idea.\n\nKeep the tests.')
p=await command(port,p,'undo'); expect(p.text).toBe('Better idea.')
const receipt=await command(port,p,'send'); expect(receipt.sent).toBe(true)
await command(port,p,'send')
expect(host.sendTurn).toHaveBeenCalledExactlyOnceWith('a2','Better idea.')
expect(host.focus).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('keeps selected text frozen through re-speaking and sending', async () => {
const selectPassage=vi.fn<NonNullable<CableHost['selectPassage']>>(async () => ({ok:true,selectionId:'sel',revision:2,text:'const answer = 42;',excerpt:'const answer = 42;',rows:1,extending:false}))
const transcribe=vi.fn().mockResolvedValueOnce('Explain.').mockResolvedValueOnce('Simplify this.')
const host=makeHost({selectPassage,transcribe}), {session,port}=await connect(host)
try {
let p=await recorded(port,{agentId:'a2',selectionId:'sel',selectionRevision:2})
expect(p.context).toBe('With selected text')
p=await recorded(port,{draftId:p.id,draftRevision:p.revision,draftOp:'replace'},false)
await command(port,p,'send')
expect(host.sendTurn).toHaveBeenCalledTimes(1)
const args=vi.mocked(host.sendTurn).mock.calls[0]
expect(args[0]).toBe('a2'); expect(args[1]).toContain('Simplify this.'); expect(args[1]).toContain('const answer = 42;')
} finally { await session.stop() }
})
it('discarding an unseen creation releases the next recording', async () => {
const {host,session,port}=await connect()
try {
const p=await recorded(port,{agentId:'a1'})
port.say({t:'voice.abort',uploadId:p.uploadId}); await settle()
expect((await recorded(port,{agentId:'a2'})).t).toBe('voice.draft')
expect(host.sendTurn).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it.each([{draftId:'missing',draftRevision:1,draftOp:'replace'}, {draftId:null},
{draftId:'id',draftRevision:1,draftOp:'append',agentId:'a2'},
{draftId:'id',draftRevision:1,draftOp:'replace',formId:'form'}])('never routes malformed draft speech %j', async fields => {
const host=makeHost({transcribe:vi.fn(async()=> 'No fallback')}), {session,port}=await connect(host)
try {
expect((await recorded(port,fields,false)).t).toBe('voice.error')
expect(host.sendTurn).not.toHaveBeenCalled(); expect(host.transcribe).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('requires a named recipient for initial review', async () => {
const host=makeHost({route:vi.fn(),routeInWindow:vi.fn()}),{session,port}=await connect(host)
try {
expect((await recorded(port)).t).toBe('voice.error')
expect(host.route).not.toHaveBeenCalled();expect(host.routeInWindow).not.toHaveBeenCalled();expect(host.sendTurn).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('ignores an old voice.end and keeps the new capture alive', async () => {
const {host,session,port}=await connect()
try {
port.say({t:'voice.begin',agentId:'a1',uploadId:'new'});port.pcm(Buffer.alloc(320))
port.say({t:'voice.end',uploadId:'old',review:true});await settle()
expect(port.types()).not.toContain('voice.draft');expect(host.sendTurn).not.toHaveBeenCalled()
port.say({t:'voice.end',uploadId:'new',review:true})
await vi.waitFor(()=>expect(port.types()).toContain('voice.draft'))
} finally { await session.stop() }
})
it('does not apply late transcription after the draft was discarded', async () => {
let resolve!:(v:string)=>void
const transcribe=vi.fn().mockResolvedValueOnce('Keep me.').mockImplementationOnce(()=>new Promise<string>(r=>{resolve=r}))
const host=makeHost({transcribe}),{session,port}=await connect(host)
try {
const p=await recorded(port,{agentId:'a1'})
port.say({t:'voice.begin',draftId:p.id,draftRevision:p.revision,draftOp:'replace',uploadId:'late'});port.pcm(Buffer.alloc(320));port.say({t:'voice.end',uploadId:'late'})
await vi.waitFor(()=>expect(transcribe).toHaveBeenCalledTimes(2));await command(port,p,'discard')
resolve('Too late.');await settle();await settle()
expect(port.sent.some(m=>m.t==='voice.draft' && m.uploadId==='late')).toBe(false)
expect(host.sendTurn).not.toHaveBeenCalled()
} finally {await session.stop()}
})
it('keeps a carried quote through discard and consumes only the accepted send', async () => {
const selectPassage=vi.fn<NonNullable<CableHost['selectPassage']>>(async()=>({ok:true,selectionId:'sel',revision:2,text:' original\nsource',excerpt:'original source',rows:2,extending:true}))
const host=makeHost({selectPassage}),{session,port}=await connect(host)
try {
port.say({t:'carry.prepare',carryId:'quote',requestId:'prepare',agentId:'a1',selectionId:'sel',revision:2})
await vi.waitFor(()=>expect(port.types()).toContain('carry.state'))
let p=await recorded(port,{agentId:'a2',carryId:'quote'})
expect(p.context).toContain('Fix login screen');expect(host.sendTurn).not.toHaveBeenCalled()
await command(port,p,'discard')
p=await recorded(port,{agentId:'a2',carryId:'quote'})
expect(p.t).toBe('voice.draft')
expect(await command(port,p,'send')).toMatchObject({sent:true,carryId:'quote'})
expect(vi.mocked(host.sendTurn).mock.calls[0][1]).toContain('> original\n> source')
expect((await recorded(port,{agentId:'a2',carryId:'quote'})).t).toBe('voice.error')
expect(host.sendTurn).toHaveBeenCalledTimes(1)
} finally {await session.stop()}
})
it('preserves the draft when cancelling an edit whose reply already arrived', async () => {
const host=makeHost({transcribe:vi.fn().mockResolvedValueOnce('Original.').mockResolvedValueOnce('Edited.')}),{session,port}=await connect(host)
try {
let p=await recorded(port,{agentId:'a1'})
p=await recorded(port,{draftId:p.id,draftRevision:p.revision,draftOp:'replace'},false)
port.say({t:'voice.abort',uploadId:p.uploadId});await settle()
expect(await command(port,p,'state')).toMatchObject({active:true,text:'Edited.'})
expect(host.sendTurn).not.toHaveBeenCalled()
} finally {await session.stop()}
})
})
describe('spoken output search purpose', () => {
const metadata = { agentId: 'a2', searchId: 'search-one', searchRevision: 3 }
const read = { ok: true as const, selectionId: 'search-one', revision: 3, rows: 1, excerpt: 'line', extending: false }
it.each([0, 2])('returns %i matches without routing, delivering, or drafting', async matches => {
const selectPassage = vi.fn<NonNullable<CableHost['selectPassage']>>(async c => c.op === 'read' ? read : {
...read, revision: 4, query: c.query, match: matches ? 1 : 0, matches, rows: matches ? 1 : 0, excerpt: matches ? 'ERROR [x]' : '',
})
const route = vi.fn(), routeInWindow = vi.fn()
const host = makeHost({ selectPassage, route, routeInWindow, transcribe: async () => 'error [x].' })
const { session, port } = await connect(host)
try {
port.say({ t: 'voice.begin', uploadId: 'search-voice', ...metadata })
port.pcm(Buffer.alloc(3200)); port.say({ t: 'voice.end', review: true })
await vi.waitFor(() => expect(port.types()).toContain('voice.search'))
expect(selectPassage.mock.calls.map(([c]) => c.op)).toEqual(['read', 'search'])
expect(selectPassage).toHaveBeenLastCalledWith({ op: 'search', agentId: 'a2', selectionId: 'search-one', revision: 3, query: 'error [x]' })
expect(port.sent.at(-1)).toMatchObject({ t: 'voice.search', uploadId: 'search-voice', agentId: 'a2', matches })
expect(host.sendTurn).not.toHaveBeenCalled(); expect(route).not.toHaveBeenCalled(); expect(routeInWindow).not.toHaveBeenCalled()
expect(port.types()).not.toContain('voice.draft')
} finally { await session.stop() }
})
it.each([{searchId:17}, {searchRevision:3}, {searchId:'bad id'}, {searchRevision:0}, {searchRevision:0x80000000},
{cmd:''}, {formId:'f'}, {selectionId:'s'}, {carryId:'c'}, {draftId:'d'}, {questionToken:'q'}])(
'malformed or mixed metadata never falls back to task input: %j', async extra => {
const transcribe = vi.fn(), route = vi.fn(), selectPassage = vi.fn()
const host = makeHost({ transcribe, route, selectPassage }), { session, port } = await connect(host)
try {
port.say({ t:'voice.begin', uploadId:'bad-search', ...metadata, ...extra,
...(Object.keys(extra).length === 1 && 'searchRevision' in extra && extra.searchRevision === 3 ? {searchId:undefined} : {}) })
port.pcm(Buffer.alloc(3200)); port.say({t:'voice.end'})
await vi.waitFor(() => expect(port.types()).toContain('voice.error'))
expect(host.sendTurn).not.toHaveBeenCalled(); expect(transcribe).not.toHaveBeenCalled(); expect(route).not.toHaveBeenCalled()
} finally { await session.stop() }
})
it('discard during transcription cancels only the original cursor', async () => {
let finish!: (s:string)=>void
const transcribe=vi.fn(()=>new Promise<string>(r=>{finish=r}))
const selectPassage=vi.fn<NonNullable<CableHost['selectPassage']>>(async()=>read)
const host=makeHost({selectPassage,transcribe}), {session,port}=await connect(host)
try {
port.say({t:'voice.begin',uploadId:'cancel-search',...metadata}); port.pcm(Buffer.alloc(3200)); port.say({t:'voice.end'})
await vi.waitFor(()=>expect(transcribe).toHaveBeenCalled())
port.say({t:'voice.abort',uploadId:'cancel-search'}); await settle(); finish('late phrase'); await settle()
expect(selectPassage.mock.calls.map(([c])=>c.op)).toEqual(['read','cancel'])
expect(selectPassage).toHaveBeenLastCalledWith({op:'cancel',agentId:'a2',selectionId:'search-one',revision:3})
expect(port.types()).not.toContain('voice.search'); expect(host.sendTurn).not.toHaveBeenCalled()
} finally {await session.stop()}
})
})