feat: voice command mapping — context-gated approve/reject over voice

While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
This commit is contained in:
Yaojia Wang
2026-07-01 10:34:51 +02:00
parent 03612323c0
commit e4c327e25e
13 changed files with 1193 additions and 19 deletions

View File

@@ -22,6 +22,8 @@ class FakeTerminalSession {
pendingTool: string | undefined = undefined
/** B4: which gate the held approval is — 'tool' (two buttons) or 'plan' (three). */
pendingGate: 'tool' | 'plan' | null = null
/** VC: nonce that counts false→true pendingApproval flips (stale-gate guard). */
pendingEpoch = 0
/** B2: latest statusLine telemetry (single source of truth). */
telemetry: unknown = null
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
@@ -86,11 +88,17 @@ const timelineDispose = vi.fn()
const mountTimeline = vi.fn(() => ({ dispose: timelineDispose }))
vi.mock('../public/timeline.js', () => ({ mountTimeline }))
const voice: { onTranscript?: (t: string) => void; onInterim?: (t: string) => void } = {}
const voice: {
onTranscript?: (t: string, confidence?: number) => void
onInterim?: (t: string) => void
} = {}
const voiceStart = vi.fn()
const voiceStop = vi.fn()
const createVoiceInput = vi.fn(
(onTranscript: (t: string) => void, opts?: { onInterim?: (t: string) => void }) => {
(
onTranscript: (t: string, confidence?: number) => void,
opts?: { onInterim?: (t: string) => void },
) => {
voice.onTranscript = onTranscript
voice.onInterim = opts?.onInterim
return { start: voiceStart, stop: voiceStop, dispose: vi.fn(), isActive: () => false }
@@ -790,3 +798,237 @@ describe('TabApp — A2 push-to-talk voice', () => {
expect(voiceStart).not.toHaveBeenCalled()
})
})
describe('TabApp — VC voice command mapping (context-gated confirm/reject)', () => {
/** Open one tab, hold a tool-gate approval, start PTT, and return its session. */
function pendingToolSession(app: InstanceType<typeof TabApp>): FakeTerminalSession {
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
return session
}
afterEach(() => {
vi.useRealTimers()
})
it('a held tool-gate "confirm" arms the approve window, then commits once after it elapses (send NOT called)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
expect(session.approve).not.toHaveBeenCalled() // armed, not yet committed
vi.advanceTimersByTime(1500)
expect(session.approve).toHaveBeenCalledTimes(1)
expect(session.send).not.toHaveBeenCalled()
})
it('a held tool-gate "拒绝" rejects immediately (fail-safe, no confirm window)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('拒绝')
expect(session.reject).toHaveBeenCalledTimes(1)
expect(session.send).not.toHaveBeenCalled()
})
it('an unmatched phrase ("deploy to prod") falls through to dictation even while pending', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('deploy to prod')
expect(session.send).toHaveBeenCalledWith('deploy to prod')
expect(session.approve).not.toHaveBeenCalled()
expect(session.reject).not.toHaveBeenCalled()
})
it('pending=false: "yes" is ordinary dictation (decision 1 — context gate)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = false
app.handleVoiceTrigger('start')
voice.onTranscript?.('yes')
expect(session.send).toHaveBeenCalledWith('yes')
expect(session.approve).not.toHaveBeenCalled()
})
it('plan gate: "confirm" falls through to dictation (decision A — tool-gate only)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connected'
session.pendingApproval = true
session.pendingGate = 'plan'
app.handleVoiceTrigger('start')
voice.onTranscript?.('confirm')
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
it('stale gate: an epoch flip after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app) // captures pendingEpoch=0 at PTT-start
session.pendingEpoch = 1 // a new pending flip landed mid-utterance
voice.onTranscript?.('confirm', 0.9)
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
it('tab switch: activating a different tab after PTT-start downgrades an approve to dictation (safeguard 5)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session0 = pendingToolSession(app) // PTT started while tab 0 is active
app.newTab() // tab 1 becomes active mid-utterance
const session1 = FakeTerminalSession.instances[1]!
session1.status = 'connected'
session1.pendingApproval = true
session1.pendingGate = 'tool'
voice.onTranscript?.('confirm', 0.9)
expect(session1.send).toHaveBeenCalledWith('confirm')
expect(session1.approve).not.toHaveBeenCalled()
expect(session0.approve).not.toHaveBeenCalled()
})
it('WS not connected: approve is never sent and never throws; flashes "未连接" (decision C)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'connecting' // not connected
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
expect(() => voice.onTranscript?.('确认', 0.9)).not.toThrow()
expect(session.approve).not.toHaveBeenCalled()
const overlay = document.getElementById('voice-confirm')
expect(overlay?.textContent).toBe('未连接')
expect(overlay?.style.display).toBe('block')
})
it('WS not connected: reject is never sent and never throws (decision C)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const session = FakeTerminalSession.instances[0]!
session.status = 'reconnecting' // not connected
session.pendingApproval = true
session.pendingGate = 'tool'
app.handleVoiceTrigger('start')
expect(() => voice.onTranscript?.('拒绝')).not.toThrow()
expect(session.reject).not.toHaveBeenCalled()
})
it('a confidence below the threshold downgrades an approve phrase to dictation (decision D)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.4)
expect(session.send).toHaveBeenCalledWith('confirm')
expect(session.approve).not.toHaveBeenCalled()
})
// ── confirm-window cancellation + commit-time re-validation (review fixes) ──
it('a "拒绝" spoken during the confirm window cancels the pending approve (reject wins)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9) // arm the window
voice.onTranscript?.('拒绝') // change of mind mid-window
expect(session.reject).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled() // stale timer must NOT commit
})
it('tapping the confirm overlay cancels the pending approve before it commits', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
document.getElementById('voice-confirm')!.click() // tap-to-cancel (§4)
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
expect(document.getElementById('voice-confirm')!.style.display).toBe('none')
})
it('a new dictation utterance during the window supersedes (cancels) the pending approve', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
voice.onTranscript?.('deploy to prod') // user moved on
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
expect(session.send).toHaveBeenCalledWith('deploy to prod')
})
it('an epoch flip DURING the confirm window drops the commit (TOCTOU re-validation)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app) // epoch 0 at PTT-start AND at dispatch
voice.onTranscript?.('confirm', 0.9) // arms against epoch 0
session.pendingEpoch = 1 // gate A resolved + gate B raised during the window
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled() // must not approve the new gate
})
it('a WS drop DURING the confirm window drops the commit (no local/server desync)', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
session.status = 'reconnecting' // socket dropped mid-window
vi.advanceTimersByTime(1500)
expect(session.approve).not.toHaveBeenCalled()
})
it('re-confirming within the window re-arms and commits exactly once', () => {
vi.useFakeTimers()
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
const session = pendingToolSession(app)
voice.onTranscript?.('confirm', 0.9)
vi.advanceTimersByTime(500)
voice.onTranscript?.('confirm', 0.9) // supersede first, re-arm
vi.advanceTimersByTime(1500)
expect(session.approve).toHaveBeenCalledTimes(1)
})
})