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

@@ -200,7 +200,7 @@ describe('createVoiceInput', () => {
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello world', true))
expect(onTranscript).toHaveBeenCalledWith('hello world')
expect(onTranscript).toHaveBeenCalledWith('hello world', expect.any(Number))
})
it('autoSend=true appends \\r to the transcript (AC-A2.4)', () => {
@@ -209,7 +209,7 @@ describe('createVoiceInput', () => {
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello\r')
expect(onTranscript).toHaveBeenCalledWith('hello\r', expect.any(Number))
})
it('autoSend=false does not append \\r (AC-A2.4)', () => {
@@ -218,7 +218,7 @@ describe('createVoiceInput', () => {
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello')
expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
})
it('omitting autoSend does not append \\r', () => {
@@ -227,7 +227,16 @@ describe('createVoiceInput', () => {
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello')
expect(onTranscript).toHaveBeenCalledWith('hello', expect.any(Number))
})
it('forwards the top alternative ASR confidence as a 2nd arg (VC decision D)', () => {
const onTranscript = vi.fn()
const voice = createVoiceInput(onTranscript)
const mock = MockRecognition.last!
voice?.start()
mock.onresult?.(makeResultEvent('hello', true))
expect(onTranscript).toHaveBeenCalledWith('hello', 1.0)
})
it('calls onInterim with interim transcript text', () => {