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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user