Files
web-terminal/test/voice-commands.test.ts
Yaojia Wang e4c327e25e 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.
2026-07-01 10:38:32 +02:00

208 lines
7.5 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
APPROVE_PHRASES,
REJECT_PHRASES,
MIN_APPROVE_CONFIDENCE,
NEGATION_PREFIXES,
normalizeTranscript,
startsWithNegation,
matchCommand,
type VoiceMatchContext,
} from '../public/voice-commands.js'
/** Default context: a held tool-gate permission (the common case under test). */
function pendingToolCtx(overrides: Partial<VoiceMatchContext> = {}): VoiceMatchContext {
return { pendingApproval: true, gate: 'tool', ...overrides }
}
describe('normalizeTranscript', () => {
it('lowercases', () => {
expect(normalizeTranscript('YES')).toBe('yes')
})
it('strips trailing ASCII punctuation', () => {
expect(normalizeTranscript('confirm.')).toBe('confirm')
})
it('strips CJK punctuation/quotes', () => {
expect(normalizeTranscript('「确认」')).toBe('确认')
})
it('strips apostrophes so contractions collapse (dont)', () => {
expect(normalizeTranscript("don't")).toBe('dont')
})
it('collapses whitespace and trims', () => {
expect(normalizeTranscript(' ')).toBe('')
expect(normalizeTranscript(' go ahead ')).toBe('go ahead')
})
it('is idempotent (already-normalized input passes through)', () => {
expect(normalizeTranscript('go ahead')).toBe('go ahead')
expect(normalizeTranscript('确认')).toBe('确认')
})
})
describe('startsWithNegation', () => {
it('detects CJK negation prefixes', () => {
expect(startsWithNegation('不可以')).toBe(true)
expect(startsWithNegation('别继续')).toBe(true)
expect(startsWithNegation('没关系')).toBe(true)
expect(startsWithNegation('未确认')).toBe(true)
expect(startsWithNegation('勿动')).toBe(true)
})
it('detects English negation prefixes', () => {
expect(startsWithNegation('no way')).toBe(true)
expect(startsWithNegation('not now')).toBe(true)
expect(startsWithNegation('dont approve')).toBe(true)
expect(startsWithNegation('cannot proceed')).toBe(true)
expect(startsWithNegation('wont continue')).toBe(true)
expect(startsWithNegation('never mind')).toBe(true)
})
it('does not flag ordinary phrases', () => {
expect(startsWithNegation('确认')).toBe(false)
expect(startsWithNegation('yes')).toBe(false)
expect(startsWithNegation('now')).toBe(false)
})
})
describe('matchCommand — context gating (decision 1)', () => {
it('returns text when pendingApproval is false, even for a command word', () => {
expect(matchCommand('yes', { pendingApproval: false, gate: null })).toBe('text')
expect(matchCommand('确认', { pendingApproval: false, gate: null })).toBe('text')
})
it('returns text for an empty/whitespace transcript', () => {
expect(matchCommand(' ', pendingToolCtx())).toBe('text')
expect(matchCommand('', pendingToolCtx())).toBe('text')
})
})
describe('matchCommand — plan gate (decision A)', () => {
it('falls through to dictation when gate is plan, not tool', () => {
expect(matchCommand('confirm', { pendingApproval: true, gate: 'plan' })).toBe('text')
expect(matchCommand('stop', { pendingApproval: true, gate: 'plan' })).toBe('text')
})
})
describe('matchCommand — held tool approve', () => {
const approvePhrases = [
'确认', '批准', '同意', '好', '好的', '好吧', '行', '可以',
'yes', 'ok', 'confirm', 'approve', 'go ahead',
]
for (const phrase of approvePhrases) {
it(`approves "${phrase}"`, () => {
expect(matchCommand(phrase, pendingToolCtx())).toBe('approve')
})
}
})
describe('matchCommand — held tool reject', () => {
const rejectPhrases = ['拒绝', '取消', '不行', 'no', 'nope', 'cancel', 'deny', 'abort', 'stop']
for (const phrase of rejectPhrases) {
it(`rejects "${phrase}"`, () => {
expect(matchCommand(phrase, pendingToolCtx())).toBe('reject')
})
}
})
describe('matchCommand — negation/filler regression (MUST NOT approve)', () => {
it('不可以 rejects (negated approve phrase is a listed reject phrase)', () => {
expect(matchCommand('不可以', pendingToolCtx())).toBe('reject')
})
it('不允许/不通过/不同意/不批准 reject', () => {
expect(matchCommand('不允许', pendingToolCtx())).toBe('reject')
expect(matchCommand('不通过', pendingToolCtx())).toBe('reject')
expect(matchCommand('不同意', pendingToolCtx())).toBe('reject')
expect(matchCommand('不批准', pendingToolCtx())).toBe('reject')
})
it('不继续 is negated but not a listed reject phrase -> text', () => {
expect(matchCommand('不继续', pendingToolCtx())).toBe('text')
})
it('English negated phrases fall through to text', () => {
expect(matchCommand("don't approve", pendingToolCtx())).toBe('text')
expect(matchCommand('do not confirm', pendingToolCtx())).toBe('text')
expect(matchCommand('cannot proceed', pendingToolCtx())).toBe('text')
expect(matchCommand("won't continue", pendingToolCtx())).toBe('text')
})
it('"ok let\'s not do that" is not a whole-utterance match -> text', () => {
expect(matchCommand("ok let's not do that", pendingToolCtx())).toBe('text')
})
it('"ok can you also check the logs" is not a whole-utterance match -> text', () => {
expect(matchCommand('ok can you also check the logs', pendingToolCtx())).toBe('text')
})
it('我觉得可以先看看 is not a whole-utterance match -> text', () => {
expect(matchCommand('我觉得可以先看看', pendingToolCtx())).toBe('text')
})
it('请继续 is not a whole-utterance match (unequal to 继续) -> text', () => {
expect(matchCommand('请继续', pendingToolCtx())).toBe('text')
})
it('now is not a listed phrase (unequal to no) -> text', () => {
expect(matchCommand('now', pendingToolCtx())).toBe('text')
})
it('我不知道 is not a whole-utterance match -> text', () => {
expect(matchCommand('我不知道', pendingToolCtx())).toBe('text')
})
})
describe('matchCommand — confidence gate (decision D)', () => {
it('low-confidence approve falls through to text', () => {
expect(matchCommand('confirm', pendingToolCtx({ confidence: 0.4 }))).toBe('text')
})
it('high-confidence approve succeeds', () => {
expect(matchCommand('confirm', pendingToolCtx({ confidence: 0.9 }))).toBe('approve')
})
it('confidence at exactly MIN_APPROVE_CONFIDENCE is sufficient', () => {
expect(matchCommand('confirm', pendingToolCtx({ confidence: MIN_APPROVE_CONFIDENCE }))).toBe(
'approve',
)
})
it('reject is unaffected by low confidence', () => {
expect(matchCommand('no', pendingToolCtx({ confidence: 0.2 }))).toBe('reject')
})
it('approve with no confidence supplied succeeds (confidence optional)', () => {
expect(matchCommand('confirm', pendingToolCtx())).toBe('approve')
})
})
describe('phrase-set integrity invariants', () => {
it('every phrase is already normalized (normalize(p) === p)', () => {
for (const p of [...APPROVE_PHRASES, ...REJECT_PHRASES]) {
expect(normalizeTranscript(p)).toBe(p)
}
})
it('APPROVE_PHRASES and REJECT_PHRASES are disjoint', () => {
const rejectSet = new Set(REJECT_PHRASES)
const overlap = APPROVE_PHRASES.filter((p) => rejectSet.has(p))
expect(overlap).toEqual([])
})
it('no APPROVE_PHRASES entry starts with a negation prefix', () => {
for (const p of APPROVE_PHRASES) {
expect(startsWithNegation(p)).toBe(false)
}
})
it('NEGATION_PREFIXES is non-empty and covers both CJK and English', () => {
expect(NEGATION_PREFIXES.length).toBeGreaterThan(0)
expect(NEGATION_PREFIXES).toContain('不')
expect(NEGATION_PREFIXES).toContain('no')
})
})