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)
})
})

207
test/voice-commands.test.ts Normal file
View File

@@ -0,0 +1,207 @@
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')
})
})

168
test/voice-confirm.test.ts Normal file
View File

@@ -0,0 +1,168 @@
/**
* test/voice-confirm.test.ts — Unit tests for voice-confirm.ts (VC / V1b).
*
* Pure timing-logic controller for the approve confirm window (PLAN_VOICE_COMMANDS §4,
* decision B). Uses vi.useFakeTimers — no DOM, no WS; setTimeout/clearTimeout only.
*
* Covered behaviours:
* - arm(onHeard, onCommit) fires onHeard synchronously/immediately.
* - onCommit fires once the windowMs timer elapses.
* - cancel() before the window elapses → onCommit is NEVER called.
* - re-arming clears the prior timer — only the latest arm's onCommit fires.
* - isArmed() reflects true while pending, false after commit/cancel/before-arm.
* - default windowMs is 1500 when not provided.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createApproveConfirm } from '../public/voice-confirm.js'
describe('voice-confirm', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('fires onHeard immediately when armed', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
// Act
confirm.arm(onHeard, onCommit)
// Assert
expect(onHeard).toHaveBeenCalledTimes(1)
expect(onCommit).not.toHaveBeenCalled()
})
it('fires onCommit after windowMs elapses', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
// Act
confirm.arm(onHeard, onCommit)
vi.advanceTimersByTime(1499)
expect(onCommit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
// Assert
expect(onCommit).toHaveBeenCalledTimes(1)
})
it('never calls onCommit when cancel() runs before the window elapses', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const onHeard = vi.fn()
const onCommit = vi.fn()
confirm.arm(onHeard, onCommit)
// Act
vi.advanceTimersByTime(500)
confirm.cancel()
vi.advanceTimersByTime(10_000)
// Assert
expect(onCommit).not.toHaveBeenCalled()
})
it('cancel() before any arm() is a no-op that does not throw', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Act / Assert
expect(() => confirm.cancel()).not.toThrow()
})
it('re-arming clears the prior timer so only the latest arm commits', () => {
// Arrange
const confirm = createApproveConfirm(1500)
const firstHeard = vi.fn()
const firstCommit = vi.fn()
const secondHeard = vi.fn()
const secondCommit = vi.fn()
// Act
confirm.arm(firstHeard, firstCommit)
vi.advanceTimersByTime(1000)
confirm.arm(secondHeard, secondCommit)
// Enough time for the FIRST window to have elapsed (1000 + 1000 = 2000 > 1500)
// but not enough for the second window (armed at t=1000, needs t=2500).
vi.advanceTimersByTime(1000)
// Assert: first window's commit never fires, second hasn't fired yet either.
expect(firstCommit).not.toHaveBeenCalled()
expect(secondCommit).not.toHaveBeenCalled()
expect(secondHeard).toHaveBeenCalledTimes(1)
// Advance past the second window's own deadline.
vi.advanceTimersByTime(500)
expect(secondCommit).toHaveBeenCalledTimes(1)
expect(firstCommit).not.toHaveBeenCalled()
})
it('isArmed() is false before any arm()', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('isArmed() is true immediately after arm() and while the window is pending', () => {
// Arrange
const confirm = createApproveConfirm(1500)
// Act
confirm.arm(vi.fn(), vi.fn())
// Assert
expect(confirm.isArmed()).toBe(true)
vi.advanceTimersByTime(500)
expect(confirm.isArmed()).toBe(true)
})
it('isArmed() is false after onCommit fires', () => {
// Arrange
const confirm = createApproveConfirm(1500)
confirm.arm(vi.fn(), vi.fn())
// Act
vi.advanceTimersByTime(1500)
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('isArmed() is false after cancel()', () => {
// Arrange
const confirm = createApproveConfirm(1500)
confirm.arm(vi.fn(), vi.fn())
// Act
confirm.cancel()
// Assert
expect(confirm.isArmed()).toBe(false)
})
it('uses a default windowMs of 1500 when not provided', () => {
// Arrange
const confirm = createApproveConfirm()
const onCommit = vi.fn()
// Act
confirm.arm(vi.fn(), onCommit)
vi.advanceTimersByTime(1499)
expect(onCommit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
// Assert
expect(onCommit).toHaveBeenCalledTimes(1)
})
})

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', () => {