diff --git a/.gitignore b/.gitignore
index d086eb67a3..f42d998afc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,16 @@ yarn-error.log*
# Bundled-plugin signing artifact (release-time build output; dev signs locally)
examples/plugins/*/signature.json
+
+# A Cappella model weights and partial downloads.
+#
+# Models live in userData and are downloaded by the user, never bundled into the
+# installer: one of them is 1.1 GB, and shipping weights would multiply the
+# download for everyone including people who never turn voice on. These patterns
+# exist so a stray copy in the working tree (a manual download, a test fixture, a
+# .part file from an interrupted transfer) cannot be committed by accident.
+*.gguf
+*.onnx
+*.part
+ggml-*.bin
+models/acappella/
diff --git a/CLAUDE.md b/CLAUDE.md
index 4ab87d73a1..e549ba7f84 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -198,6 +198,19 @@ Use "agent" in user-facing language. Reserve "session" for provider-level conver
- **Cue** - Event-driven automation system (Maestro Cue), gated as an Encore Feature. Watches for file changes, time intervals, agent completions, GitHub PRs/issues, and pending markdown tasks to trigger automated prompts. Configured via `.maestro/cue.yaml` per project.
- **Cue Modal** - Dashboard for managing Cue subscriptions and viewing activity (`CueModal.tsx`)
+### Voice
+
+- **A Cappella** - The voice interface, gated as an Encore Feature and off by default. Speech in, routing to an agent and tab, spoken reply back. The session itself lives in the MAIN process (`src/main/acappella/voice-session-service.ts`); every renderer holds a projection of its event stream, never authority. Architecture: [docs/architecture/acappella/](docs/architecture/acappella/). User docs: [docs/voice-mode.md](docs/voice-mode.md).
+- **Conductor** - The routing model that decides which agent and tab an utterance meant. It is a router, not a second assistant: it never answers a question itself.
+- **Voice HUD** - The one on-screen surface for a session (`VoiceHud.tsx`). Minimize keeps the session and hands the indicator to the Left Bar; close ENDS it.
+- **Voice floor** - Which microphone currently holds the session. One floor app-wide, held locally or by a paired device.
+
+Three invariants worth knowing before touching any of it, because each was a shipped bug:
+
+1. **Nothing starts itself.** Enabling the feature opens no device, downloads nothing, registers no hotkey, and renders no surface. A session begins only from an explicit trigger (composer microphone, global hotkey, command palette, Left Bar menu, wake word, paired phone).
+2. **One session belongs to ONE window.** Voice events are broadcast to every renderer like every other main -> renderer push, so a surface that does not gate on `useOwnsVoiceSession()` appears in all of them at once.
+3. **A missing capability is refused by name, never substituted.** The gate (`models/capability-gate.ts`) reports the specific slot and its recovery. It never quietly swaps in a cloud provider for a local one that will not load - that would spend the user's money and ship their microphone somewhere they did not choose.
+
### Agent States (color-coded)
- **Green** - Ready/idle
@@ -322,57 +335,60 @@ src/
## Key Files for Common Tasks
-| Task | Primary Files |
-| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Add IPC handler | `src/main/index.ts`, `src/main/preload.ts` |
-| Add UI component | `src/renderer/components/` |
-| Add browser/mobile touch affordance | `src/renderer/components/`, `src/renderer/utils/touch.ts`, `src/renderer/hooks/utils/` (gate on `isCoarsePointer()`; PWA assets in `src/web/public/`) |
-| Add keyboard shortcut | `src/renderer/constants/shortcuts.ts`, `App.tsx` |
-| Add theme | `src/renderer/constants/themes.ts` |
-| Add modal | Component + `src/renderer/constants/modalPriorities.ts` |
-| Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] |
-| Add setting | `src/shared/settingsMetadata.ts` (metadata), `src/renderer/stores/settingsStore.ts`, `src/main/stores/defaults.ts`, AND `src/renderer/components/Settings/searchableSettings.ts` + `data-setting-id` wrapper on rendered control (see [[CLAUDE-PATTERNS.md]] §3) |
-| Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` |
-| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) or edit via **Maestro Prompts** tab in Settings |
-| Customize prompts | Use **Maestro Prompts** tab in Settings, or edit `userData/core-prompts-customizations.json` |
-| Add new prompt | `src/prompts/*.md`, `src/shared/promptDefinitions.ts` (add to `CORE_PROMPTS` array and `PROMPT_IDS`) |
-| Add Spec-Kit command | `src/prompts/speckit/`, `src/main/speckit-manager.ts` |
-| Add OpenSpec command | `src/prompts/openspec/`, `src/main/openspec-manager.ts` |
-| Add CLI command | `src/cli/commands/`, `src/cli/index.ts` |
-| Add new agent | `src/shared/agentIds.ts`, `src/main/agents/definitions.ts`, `src/main/agents/capabilities.ts`, `src/shared/agentMetadata.ts` - see [AGENT_SUPPORT.md](AGENT_SUPPORT.md) |
-| Add agent output parser | `src/main/parsers/`, `src/main/parsers/index.ts` |
-| Add agent session storage | `src/main/storage/` (extend `BaseSessionStorage`), `src/main/storage/index.ts` |
-| Add agent error patterns | `src/main/parsers/error-patterns.ts` |
-| Add agent context window | `src/shared/agentConstants.ts` (`DEFAULT_CONTEXT_WINDOWS`, `FALLBACK_CONTEXT_WINDOW`) |
-| Add playbook feature | `src/cli/services/playbooks.ts` |
-| Add marketplace playbook | `src/main/ipc/handlers/marketplace.ts` (import from GitHub) |
-| Playbook import/export | `src/main/ipc/handlers/playbooks.ts` (ZIP handling with assets) |
-| Modify wizard flow | `src/renderer/components/Wizard/` (see [[CLAUDE-WIZARD.md]]) |
-| Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` |
-| Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) |
-| Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) |
-| Add documentation screenshot | `docs/screenshots/` (PNG, kebab-case naming) |
-| MCP server integration | See [MCP Server docs](https://docs.runmaestro.ai/mcp-server) |
-| Add stats/analytics feature | `src/main/stats-db.ts`, `src/main/ipc/handlers/stats.ts` |
-| Add Usage Dashboard chart | `src/renderer/components/UsageDashboard/` |
-| Add Document Graph feature | `src/renderer/components/DocumentGraph/`, `src/main/ipc/handlers/documentGraph.ts` |
-| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` |
-| Add performance metrics | `src/shared/performance-metrics.ts` |
-| Capture/analyze perf trace | `src/main/profiling/` (Chromium contentTracing capture), `scripts/analyze-perf-trace.mjs` (offline analysis), `CLAUDE-PERFORMANCE.md` -> Field Performance Traces |
-| Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` |
-| Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) |
-| Modify file preview tabs | `TabBar.tsx`, `FilePreview.tsx`, `MainPanel.tsx` (see ARCHITECTURE.md → File Preview Tab System) |
-| Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` |
-| Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler |
-| Modify history components | `src/renderer/components/History/` |
-| Modify history activity graph | `src/renderer/components/History/ActivityGraph.tsx`, `src/main/utils/history-bucket-cache.ts` (disk-cached aggregates), `src/main/utils/history-bucket-builder.ts` |
-| Modify Auto Run Thought Stream | `src/renderer/stores/thoughtStreamStore.ts` (in-memory capture + `groupThoughtsIntoBlocks`), `src/renderer/components/ThoughtStreamPanel.tsx` (panel), `src/renderer/hooks/agent/internal/useThoughtStreamCaptureListener.ts` (taps `process:thinking-chunk`) |
-| Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` |
-| Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` |
-| Modify Cue modal | `src/renderer/components/CueModal.tsx` |
-| Configure Cue engine | `src/main/cue/cue-engine.ts`, `src/main/ipc/handlers/cue.ts` |
-| Add terminal feature | `src/renderer/components/XTerminal.tsx`, `src/renderer/components/TerminalView.tsx` |
-| Modify terminal tabs | `src/renderer/utils/terminalTabHelpers.ts`, `src/renderer/stores/tabStore.ts` |
+| Task | Primary Files |
+| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Add IPC handler | `src/main/index.ts`, `src/main/preload.ts` |
+| Add UI component | `src/renderer/components/` |
+| Add browser/mobile touch affordance | `src/renderer/components/`, `src/renderer/utils/touch.ts`, `src/renderer/hooks/utils/` (gate on `isCoarsePointer()`; PWA assets in `src/web/public/`) |
+| Add keyboard shortcut | `src/renderer/constants/shortcuts.ts`, `App.tsx` |
+| Add theme | `src/renderer/constants/themes.ts` |
+| Add modal | Component + `src/renderer/constants/modalPriorities.ts` |
+| Add tab overlay menu | See Tab Hover Overlay Menu pattern in [[CLAUDE-PATTERNS.md]] |
+| Add setting | `src/shared/settingsMetadata.ts` (metadata), `src/renderer/stores/settingsStore.ts`, `src/main/stores/defaults.ts`, AND `src/renderer/components/Settings/searchableSettings.ts` + `data-setting-id` wrapper on rendered control (see [[CLAUDE-PATTERNS.md]] §3) |
+| Add template variable | `src/shared/templateVariables.ts`, `src/renderer/utils/templateVariables.ts` |
+| Modify system prompts | `src/prompts/*.md` (wizard, Auto Run, etc.) or edit via **Maestro Prompts** tab in Settings |
+| Customize prompts | Use **Maestro Prompts** tab in Settings, or edit `userData/core-prompts-customizations.json` |
+| Add new prompt | `src/prompts/*.md`, `src/shared/promptDefinitions.ts` (add to `CORE_PROMPTS` array and `PROMPT_IDS`) |
+| Add Spec-Kit command | `src/prompts/speckit/`, `src/main/speckit-manager.ts` |
+| Add OpenSpec command | `src/prompts/openspec/`, `src/main/openspec-manager.ts` |
+| Add CLI command | `src/cli/commands/`, `src/cli/index.ts` |
+| Add new agent | `src/shared/agentIds.ts`, `src/main/agents/definitions.ts`, `src/main/agents/capabilities.ts`, `src/shared/agentMetadata.ts` - see [AGENT_SUPPORT.md](AGENT_SUPPORT.md) |
+| Add agent output parser | `src/main/parsers/`, `src/main/parsers/index.ts` |
+| Add agent session storage | `src/main/storage/` (extend `BaseSessionStorage`), `src/main/storage/index.ts` |
+| Add agent error patterns | `src/main/parsers/error-patterns.ts` |
+| Add agent context window | `src/shared/agentConstants.ts` (`DEFAULT_CONTEXT_WINDOWS`, `FALLBACK_CONTEXT_WINDOW`) |
+| Add playbook feature | `src/cli/services/playbooks.ts` |
+| Add marketplace playbook | `src/main/ipc/handlers/marketplace.ts` (import from GitHub) |
+| Playbook import/export | `src/main/ipc/handlers/playbooks.ts` (ZIP handling with assets) |
+| Modify wizard flow | `src/renderer/components/Wizard/` (see [[CLAUDE-WIZARD.md]]) |
+| Add tour step | `src/renderer/components/Wizard/tour/tourSteps.ts` |
+| Modify file linking | `src/renderer/utils/remarkFileLinks.ts` (remark plugin for `[[wiki]]` and path links) |
+| Add documentation page | `docs/*.md`, `docs/docs.json` (navigation) |
+| Add documentation screenshot | `docs/screenshots/` (PNG, kebab-case naming) |
+| MCP server integration | See [MCP Server docs](https://docs.runmaestro.ai/mcp-server) |
+| Add stats/analytics feature | `src/main/stats-db.ts`, `src/main/ipc/handlers/stats.ts` |
+| Add Usage Dashboard chart | `src/renderer/components/UsageDashboard/` |
+| Add Document Graph feature | `src/renderer/components/DocumentGraph/`, `src/main/ipc/handlers/documentGraph.ts` |
+| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` |
+| Add performance metrics | `src/shared/performance-metrics.ts` |
+| Capture/analyze perf trace | `src/main/profiling/` (Chromium contentTracing capture), `scripts/analyze-perf-trace.mjs` (offline analysis), `CLAUDE-PERFORMANCE.md` -> Field Performance Traces |
+| Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` |
+| Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) |
+| Modify file preview tabs | `TabBar.tsx`, `FilePreview.tsx`, `MainPanel.tsx` (see ARCHITECTURE.md → File Preview Tab System) |
+| Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` |
+| Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler |
+| Modify history components | `src/renderer/components/History/` |
+| Modify history activity graph | `src/renderer/components/History/ActivityGraph.tsx`, `src/main/utils/history-bucket-cache.ts` (disk-cached aggregates), `src/main/utils/history-bucket-builder.ts` |
+| Modify Auto Run Thought Stream | `src/renderer/stores/thoughtStreamStore.ts` (in-memory capture + `groupThoughtsIntoBlocks`), `src/renderer/components/ThoughtStreamPanel.tsx` (panel), `src/renderer/hooks/agent/internal/useThoughtStreamCaptureListener.ts` (taps `process:thinking-chunk`) |
+| Add A Cappella voice feature | `src/main/acappella/` (session, providers, router, audio, wake, transport), `src/main/ipc/handlers/acappella.ts`, `src/renderer/components/ACappella/` - read [docs/architecture/acappella/system-overview.md](docs/architecture/acappella/system-overview.md) first |
+| Add a voice provider (STT/TTS/brain) | `src/main/acappella/providers/` + `src/shared/acappella/provider-catalog.ts`. Register in `provider-registry.ts`; NEVER substitute one provider for another (see `capability-gate.ts`) |
+| Surface a voice session in the UI | `useOwnsVoiceSession()` in `src/renderer/components/ACappella/` - voice events are broadcast to EVERY window, so any new voice surface must gate on it or it renders in all of them |
+| Add Cue event type | `src/main/cue/cue-types.ts`, `src/main/cue/cue-engine.ts` |
+| Add Cue template variable | `src/shared/templateVariables.ts`, `src/main/cue/cue-executor.ts` |
+| Modify Cue modal | `src/renderer/components/CueModal.tsx` |
+| Configure Cue engine | `src/main/cue/cue-engine.ts`, `src/main/ipc/handlers/cue.ts` |
+| Add terminal feature | `src/renderer/components/XTerminal.tsx`, `src/renderer/components/TerminalView.tsx` |
+| Modify terminal tabs | `src/renderer/utils/terminalTabHelpers.ts`, `src/renderer/stores/tabStore.ts` |
---
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
index a84699465e..54b94314a9 100644
--- a/build/entitlements.mac.plist
+++ b/build/entitlements.mac.plist
@@ -8,8 +8,11 @@
com.apple.security.cs.disable-library-validation
+
com.apple.security.device.audio-input
-
+
com.apple.security.automation.apple-events
diff --git a/docs/.mintignore b/docs/.mintignore
index caafba3181..c3fbc8024e 100644
--- a/docs/.mintignore
+++ b/docs/.mintignore
@@ -3,3 +3,8 @@
# `{...}` examples that are valid Markdown but not valid MDX, so exclude the whole
# directory from the Mintlify build to keep the deploy green.
agent-guides/
+
+# Internal architecture notes and ADRs (structured markdown with YAML front
+# matter and `[[wiki-links]]` for DocGraph, not part of docs.json navigation).
+# Same MDX-hostile `{...}` type examples as above.
+architecture/
diff --git a/docs/agent-guides/SHARED-UTILS.md b/docs/agent-guides/SHARED-UTILS.md
index a0871d36a6..b1cdc0f780 100644
--- a/docs/agent-guides/SHARED-UTILS.md
+++ b/docs/agent-guides/SHARED-UTILS.md
@@ -508,6 +508,35 @@ A checked task is stepped over entirely, marker and all. That keeps a half-finis
---
+## A Cappella Encore Flag (`src/shared/acappella/feature-flag.ts` - Both)
+
+The ONE reader of the `encoreFeatures.aCappella` flag. Do NOT hand-roll
+`flags.aCappella === true` at a new call site: the surfaces that gate on it are
+not one system (IPC handlers, the hotkey installation, the WebSocket signaling
+adapter, the transport, the debug-package collector), they each control a real
+resource - a microphone, a global shortcut, a Bonjour advert - and a surface that
+disagrees with the rest leaves one of those running behind a switch the user
+believes is off. Five byte-identical copies had already accumulated.
+
+| Function / Constant | Signature | Purpose |
+| -------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `isACappellaEnabled(store)` | `(EncoreFlagStore) => bool` | True only for the literal `true`. A hand-edited `"true"` or `1` reads as OFF, which is the safe direction for a mic feature. |
+| `requireACappellaEnabled(store)` | `(EncoreFlagStore) => void` | Throw `ACappellaDisabled` unless the flag is on. What a gated IPC handler calls. |
+| `ACAPPELLA_DISABLED_ERROR` | `'ACappellaDisabled'` | The stable error string the renderer maps. Not prose - a sentence here would be a wire contract. |
+| `EncoreFlagStore` | `{ get(key, default?) }` | The narrow store slice this needs, so an electron-store or a plain object both satisfy it. |
+
+**Turning the flag off is a teardown, not just a gate.**
+`shutdownACappellaForDisable()` in `src/main/ipc/handlers/acappella.ts` is what
+`main/index.ts` runs from its `encoreFeatures` watcher: it stops the session,
+drops the audio bridge, disposes the inference pipeline (which is also what lets
+reclaim-disk delete model files on Windows), and calls
+`ACappellaTransport.standDown()` for the advert and the connected phones. It
+deliberately does NOT dispose the transport or the hotkey installation - both are
+built once per process, so tearing them down would mean switching the feature
+back on did nothing until the next restart.
+
+---
+
## Synopsis Parsing (`src/shared/synopsis.ts` - Both)
| Function / Constant | Signature | Purpose |
diff --git a/docs/architecture/acappella/conversation-acceptance.md b/docs/architecture/acappella/conversation-acceptance.md
new file mode 100644
index 0000000000..c0a04d90dc
--- /dev/null
+++ b/docs/architecture/acappella/conversation-acceptance.md
@@ -0,0 +1,207 @@
+---
+type: reference
+title: A Cappella Conversation Acceptance Checklist
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - testing
+ - speech
+related:
+ - '[[latency-baseline]]'
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+---
+
+# A Cappella Conversation Acceptance Checklist
+
+The speech layer is the one part of A Cappella that automated tests cannot sign off. A scheduler
+can be proven to deliver sentences in order without a human confirming that the result sounds like
+a person talking, and a barge-in controller can tear down in the right order while still feeling
+laggy to the person doing the interrupting. This document is the by-hand pass: what to say, what to
+listen for, and which module to open when a check fails.
+
+Run it on the oldest machine available. Every check below is a latency judgement in disguise, and a
+fast machine hides the failures this list exists to find.
+
+## Precondition: the layer has to be reachable
+
+The speech layer is wired as of 2026-08-15. `VoiceSessionService` composes the translator, the
+scheduler, the barge-in controller, the detail buffer, and the background announcer directly, and
+`src/main/ipc/handlers/acappella.ts` builds the agent-output tap over the process manager and hands
+it in as `agentReplyStream`. Confirm both seams before treating any failure below as a bug:
+
+```bash
+grep -rn "ConversationalTranslator\|SpeechScheduler\|BargeInController" \
+ src/main/acappella/voice-session-service.ts
+grep -rn "createAgentOutputTap\|agentReplyStream" src/main/ipc/handlers/acappella.ts
+```
+
+Two things can still leave the layer inert at runtime, and both are silent:
+
+- **No process manager.** `agentReplyStream` is optional, and without it the session waits for a
+ whole reply through `submitAgentReply()` - the `buffered` counterfactual the latency harness
+ measures the shipped path against in [[latency-baseline]]. This is the mock tier and the dev
+ harness. In the packaged app the manager is passed from `src/main/ipc/bootstrap/index.ts`.
+- **A focus-only dispatch.** The tap is only armed when the dispatch actually sent a prompt, so
+ "switch to the backend agent" is correctly followed by silence rather than by the tab's previous
+ output being read aloud.
+
+One thing is genuinely not wired yet: `focusTarget`, which is what a "show me" needs to put a tab on
+screen. Nothing supplies it, so check 5's `show` case focuses nothing today. That is a renderer
+round trip and belongs with the Phase 09 tab affordances.
+
+## What else you need
+
+Four things, none of which live in the repo:
+
+1. The three native runtimes installed. They are declared in
+ `src/shared/acappella/native-runtimes.ts` but are not yet `package.json` dependencies. See
+ [[packaging-notes]].
+2. The model set downloaded, from **Settings > Plugins > A Cappella > Models**.
+3. API keys for OpenAI and ElevenLabs if you are testing anything other than the fully local
+ configuration.
+4. A microphone, and a quiet room. Barge-in checks are meaningless over a fan.
+
+## The checks
+
+Each check is one utterance, one thing to listen for, and one place to look when it fails.
+
+### 1. The first word arrives before the agent has finished writing
+
+**Say:** "ask backend to summarise everything that changed in the router this week"
+
+**Listen for:** speech starting while the agent is visibly still writing in its tab. The point is
+not that speech is fast, it is that speech and writing overlap. If the reply finishes rendering in
+silence and only then does anything get said, the tap is not cutting at a completed thought.
+
+**When it fails:** `speech/agent-output-tap.ts`. `DEFAULT_MIN_CHUNK_CHARS` is 200, so a reply whose
+first paragraph is shorter than that waits for a paragraph break. Raise the agent's verbosity before
+concluding the tap is broken.
+
+### 2. Sentences play without gaps
+
+**Say:** anything that produces a four or five sentence answer.
+
+**Listen for:** the seam between sentence one and sentence two. A pause there is a provider round
+trip that the lookahead should have hidden.
+
+**When it fails:** `speech/speech-scheduler.ts`. `DEFAULT_LOOKAHEAD` is 1, meaning two sentences are
+in flight at once. A slow TTS provider may need 2. Note that the scheduler delivers strictly in
+order regardless of lookahead, so raising it cannot reorder speech.
+
+### 3. Interrupting stops audio immediately and captures your first word
+
+**Say:** anything long, then talk over it mid-sentence. Start your interruption with a distinct word
+you can check for, such as "stop, actually, what about the tests".
+
+**Listen for:** audio dropping within a beat, not at the end of the current sentence. Then check the
+transcript: your first word has to be there. "Actually, what about the tests" with the leading
+"stop" missing means the pre-roll is not reaching the reopened floor.
+
+**When it fails:** `speech/barge-in.ts`. Ducking is a 20 ms ramp to `DEFAULT_DUCK_GAIN` 0.15, so what
+you hear should be a fast fade rather than a hard cut. The order matters and is deliberate: duck,
+flush playback, cancel synthesis, cancel the translator stream, reopen the floor. A missing first
+word is the last step; audio that keeps playing is one of the first two.
+
+Also confirm the negative case: the assistant must not interrupt itself. Let a long reply play in
+full without speaking. Any self-interrupt means AEC leakage is beating the 250 ms
+`DEFAULT_GUARD_MS` window.
+
+### 4. "Tell me more" drills into real detail instantly
+
+**Say:** after any substantial reply, "tell me more".
+
+**Listen for:** detail arriving with no perceptible think time and no new work in the agent's tab.
+The whole point of the detail buffer is that the follow-up costs nothing. If the agent tab shows a
+new turn, the utterance was routed instead of matched as a follow-up.
+
+**Then check the siblings:** "read that again" repeats what was actually spoken, not what was
+queued. "What was the file" speaks a basename, never a path read character by character. "Show me"
+focuses the tab and says nothing at all.
+
+**When it fails:** `speech/drill-down.ts`. Intent matching is ordered, with `show` ahead of `file`.
+
+### 5. Nothing markdown-shaped is ever read aloud
+
+**Say:** something that forces a code-heavy answer, such as "ask backend to show me the diff for the
+router change".
+
+**Listen for:** silence over the diff. The intro line should be spoken, the fence never. This is the
+check most likely to surface something ugly, because it is where the tap's filtering and the
+translator's markdown stripping have to agree.
+
+**Known rough edge:** a diff-heavy reply produces a real multi-second silence mid-turn. The intro is
+spoken, the fence is correctly skipped, and the hang notice cannot cover the gap because the diff
+keeps arriving as `data` and keeps resetting the 20 s timer. Nothing is malfunctioning and it is
+still the worst listening experience the harness produces. See [[latency-baseline]].
+
+### 6. A background completion waits for a pause
+
+**Say:** dispatch to a second agent, then start a conversation with the first while the second
+works.
+
+**Listen for:** the second agent's completion never landing on top of your conversation, and when it
+does land, naming its source ("the backend agent finished the migration").
+
+**When it fails:** `speech/background-announcer.ts`. The setting is `speakBackgroundCompletions`
+under the `acappella` settings key, with `on | off | auto`. `auto` is the default and resolves to on
+for the Conductor scope, off inside a focused agent session, so a silent announcement inside a
+focused session is correct behaviour rather than a bug.
+
+## The visual checks
+
+The six checks above are about what you hear. These are about what you see, and they are equally
+outside what an automated test can sign off: jsdom has no layout engine, so every assertion about
+clipping, readability, and contrast in the test suite is an assertion about VALUES rather than about
+pixels. The exception is colour contrast, which `VoiceAccessibility.test.tsx` verifies against every
+shipped theme with `contrastRatio()`, so this pass is looking for layout and legibility rather than
+re-checking the numbers.
+
+Run these in at least **three themes, one of which must be a light theme**. Light themes are where a
+widget built against a dark default falls apart, and A Cappella's HUD is drawn almost entirely from
+theme colours.
+
+### 7. The HUD is readable and nothing clips
+
+**Do:** open a session, drag the HUD to each corner, and let a turn run through listening, thinking,
+and speaking in each theme.
+
+**Look for:** the five indicator states distinguishable at a glance and by SHAPE, not only by hue
+(outlined ring, filled disc, dashed spinner ring, error ring). The bound scope in the agent's own
+colour, legible against the panel. Nothing spilling out of the widget.
+
+### 8. Minimize keeps the audio, close stops it
+
+**Do:** while a reply is being spoken, press the `-` button. Then restore, and press the ESC pill.
+
+**Look for:** minimize collapsing the HUD to a small indicator with the reply STILL AUDIBLE and a
+visible way back. Close stopping the speech and ending the session.
+
+A control that hides itself must not silently leave a hot microphone, and a close button that only
+hides leaves audio coming from nowhere. This is the one pair in the feature where getting it
+backwards is a safety problem rather than a papercut.
+
+### 9. The transcript survives a restart and does not interrupt
+
+**Do:** turn the transcript on from the HUD, quit and reopen Maestro, then turn it off mid-reply.
+
+**Look for:** the transcript still open after the restart, and turning it off leaving the
+conversation running - the speech does not stop, the floor is not released, and the next sentence
+still arrives.
+
+### 10. Reduced motion actually stops the motion
+
+**Do:** turn on the OS "reduce motion" setting while a session is live (macOS: System Settings ->
+Accessibility -> Display -> Reduce motion).
+
+**Look for:** the animations stopping WITHOUT a restart, and each state still distinguishable
+without them. This widget is designed to be left on screen all day, which is exactly why a
+permanently animating one is a real problem rather than a preference.
+
+## Recording the result
+
+Numbers from the same session go in the **Measured results** table in [[latency-baseline]]: press
+**Read last turn** on the Models page and paste the copied JSON. Three turns per configuration,
+record the median. The first turn of a local configuration includes the model load and is not
+representative of a conversation.
diff --git a/docs/architecture/acappella/decisions/adr-001-webrtc-transport.md b/docs/architecture/acappella/decisions/adr-001-webrtc-transport.md
new file mode 100644
index 0000000000..fac451e1f3
--- /dev/null
+++ b/docs/architecture/acappella/decisions/adr-001-webrtc-transport.md
@@ -0,0 +1,132 @@
+---
+type: decision
+title: 'ADR-001: WebRTC for the phone audio leg'
+created: 2026-08-14
+tags:
+ - voice
+ - architecture
+ - acappella
+ - adr
+related:
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+ - '[[adr-002-main-process-session]]'
+ - '[[transport-and-pairing]]'
+---
+
+# ADR-001: WebRTC for the phone audio leg
+
+**Status:** Accepted
+**Date:** 2026-08-14
+**Deciders:** A Cappella Phase 01
+
+## Context
+
+A Cappella's iPhone client carries live microphone audio to the desktop and live speech back. The
+desktop already runs an authenticated WebSocket at `/$TOKEN/ws`
+(`src/main/web-server/routes/wsRoute.ts`) with an established client registry and a broadcast
+fan-out (`src/main/web-server/services/broadcastService.ts`). The obvious cheap move is to encode
+Opus frames and push them over that socket in both directions.
+
+The obvious cheap move is wrong, and it is worth writing down why so nobody re-proposes it in a
+later phase.
+
+## Decision
+
+**The phone's media leg is WebRTC. The existing authenticated WebSocket carries signaling only.**
+
+Signaling (offer, answer, ICE candidates) rides `/$TOKEN/ws`, so the phone leg inherits the token
+auth, the client registry, and the connection lifecycle that already exist. Media does not touch
+that socket.
+
+Control events from [[voice-session-protocol]] also ride the WebSocket. Only audio moves to the
+peer connection.
+
+## Rationale
+
+### Acoustic echo cancellation
+
+This is the decisive reason. The phone plays the assistant's speech through its speaker while its
+microphone is open for barge-in. Without echo cancellation the microphone hears the assistant,
+the STT transcribes it, and the system talks to itself. Barge-in, the single most important
+interaction in the whole feature, becomes impossible.
+
+WebRTC's audio pipeline provides AEC, noise suppression, and automatic gain control as part of
+`getUserMedia` plus the peer connection, tuned by people who have spent two decades on it. A raw
+WebSocket carrying Opus frames provides none of them. Building AEC by hand is not a weekend
+project; it is the reason conferencing products have audio teams.
+
+### Jitter buffer
+
+Networks deliver audio unevenly. WebRTC maintains an adaptive jitter buffer that trades a few
+milliseconds of latency for smooth playback and adapts as conditions change. Over a WebSocket we
+would write our own, and a hand-rolled fixed buffer is either too small (audible gaps) or too
+large (barge-in feels laggy, which reads as the assistant ignoring you).
+
+### Packet loss concealment
+
+Opus over WebRTC conceals lost packets by synthesizing plausible audio for the gap. A lost frame
+degrades quality slightly. TCP has no concept of concealment: a lost segment is retransmitted,
+and the whole stream **stalls** behind it (head-of-line blocking). On a phone walking between WiFi
+and cellular, that turns a small loss into a visible freeze in a conversation.
+
+### UDP versus TCP
+
+WebSockets are TCP. For real-time media, TCP's reliability guarantee is the wrong guarantee: a
+retransmitted audio packet that arrives 400 ms late is worthless, and waiting for it delays every
+packet behind it. WebRTC uses UDP, where late audio is simply dropped and the conversation keeps
+moving. Plus ICE/STUN gives NAT traversal and network-change survival (WiFi to cellular) that a
+plain socket would need re-connect logic to approximate.
+
+## Alternatives considered
+
+### WebSocket plus Opus frames
+
+Rejected. Cheapest to build, and it fails on all four axes above. The critical failure is AEC:
+without it, open-mic barge-in cannot work at all, and barge-in is not optional in this design
+(see [[voice-session-protocol]], "Invariants").
+
+### Native iOS audio session with a custom UDP protocol
+
+Rejected. This is re-implementing WebRTC with fewer eyes on it, and it forfeits the browser-based
+fallback client entirely.
+
+### Push to talk only, half duplex, over the existing socket
+
+Rejected as the architecture, though it remains a usable **mode**. Half duplex sidesteps echo by
+never having the microphone open during playback, which makes a WebSocket viable. But it removes
+barge-in, and barge-in is the difference between talking to a system and waiting for one. Half
+duplex can ship as a low-bandwidth fallback on top of the WebRTC design; the reverse (adding full
+duplex to a WebSocket design later) means rebuilding the transport.
+
+## Consequences
+
+**Positive**
+
+- Barge-in works with the speaker on, which is the whole interaction model.
+- Network changes and packet loss degrade gracefully instead of freezing.
+- The browser gets the same client for free, so a laptop can be a voice client too.
+- Signaling reuses the existing authenticated socket, so there is no second auth surface.
+
+**Negative**
+
+- More moving parts: ICE, STUN, and possibly TURN for hostile networks. The desktop needs a
+ WebRTC peer implementation in the main process, which is a real dependency rather than a few
+ lines of `ws`.
+- Local network discovery and certificate handling need design work in the phone phase.
+- Debugging is harder: media that does not flow has more possible causes than a socket that is
+ closed.
+
+**Neutral**
+
+- The [[voice-session-protocol]] event stream is unaffected. It was designed transport-agnostic
+ precisely so this decision could be made independently, and so a client with no media leg at all
+ (the desktop dev harness) is still a first-class client.
+
+## Related
+
+- [[system-overview]] - where the phone sits in the client model.
+- [[voice-session-protocol]] - the control-plane events that ride the WebSocket.
+- [[adr-002-main-process-session]] - why there is a session in main for the phone to be a peer of.
+- [[transport-and-pairing]] - how the decision was actually built: pairing, signaling, ICE, TURN,
+ and the connection matrix.
diff --git a/docs/architecture/acappella/decisions/adr-002-main-process-session.md b/docs/architecture/acappella/decisions/adr-002-main-process-session.md
new file mode 100644
index 0000000000..4884bbfd05
--- /dev/null
+++ b/docs/architecture/acappella/decisions/adr-002-main-process-session.md
@@ -0,0 +1,144 @@
+---
+type: decision
+title: 'ADR-002: The voice session is headless in the main process'
+created: 2026-08-14
+tags:
+ - voice
+ - architecture
+ - acappella
+ - adr
+related:
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+ - '[[adr-001-webrtc-transport]]'
+---
+
+# ADR-002: The voice session is headless in the main process
+
+**Status:** Accepted
+**Date:** 2026-08-14
+**Deciders:** A Cappella Phase 01
+
+## Context
+
+A voice session owns a state machine, a monotonic event sequence, three providers, and a dispatch
+path into agents and tabs. It has to live somewhere.
+
+The renderer is the tempting home. Maestro already does Web Speech dictation there
+(`src/renderer/hooks/utils/useVoiceInput.ts`), the microphone and speaker are trivially reachable
+from a renderer, `MediaStream` and `AudioWorklet` are browser APIs, and all the state stores are
+already there. Building it in the renderer would be faster in Phase 01.
+
+## Decision
+
+**`VoiceSessionService` lives in `src/main/acappella/`, is headless, and is transport-agnostic.**
+It may not reference a `BrowserWindow`, a React store, or the DOM. Every UI, on every device, is a
+client of [[voice-session-protocol]].
+
+## Rationale
+
+### The iPhone is a peer, not a port
+
+This is the reason that outranks the others. If the session lives in the renderer, the phone
+either talks to the renderer (making one window a server for a physical device, which breaks the
+moment that window closes or Maestro goes multi-window) or gets a second, parallel session
+implementation in main. A second implementation means two state machines that drift, two routing
+paths, two barge-in semantics, and two sets of bugs.
+
+With the session in main, the phone and the desktop HUD are the same kind of thing: subscribers to
+one event stream, senders of the same three commands. Adding a client is adding a transport
+adapter, not a feature.
+
+### Renderer lifetime is not session lifetime
+
+A renderer can be reloaded, hidden, closed, or moved between windows. Maestro is already
+multi-window, and windows own subsets of agents. A voice session that dies because the user closed
+the window it happened to start in is broken by construction, and one that has to be handed
+between windows is worse. Conversations are longer-lived than views.
+
+### Dispatch authority already lives in main
+
+Routing a decision into agents and tabs needs the roster, and main already holds it: the sessions
+store (`src/main/stores/getters.ts`, read the same way `registerSessionCallbacks` reads it) plus
+the `remote:*` bridge the web server uses to ask the renderer to act
+(`src/main/web-server/callbacks/tabCallbacks.ts`). A renderer-owned session would either duplicate
+that or reach back into main for every step anyway.
+
+Note the asymmetry this creates and accepts: main holds the roster but has **no** tab authority.
+Tab state lives in the renderer, so `executeRouteDecision()` sends `remote:newTab` /
+`remote:selectTab` / `remote:renameTab` and waits for confirmation, exactly as the web path does.
+Main is the decider; the renderer stays the executor.
+
+### Providers are Node-shaped
+
+Local Whisper and Kokoro are native modules or child processes. Cloud realtime sessions want a
+long-lived socket with a secret key. A renderer has neither native module access nor a safe place
+for keys, so provider work would end up proxied through IPC in every case. Putting the session
+where the providers already have to run removes a whole layer of marshalling from the hot path,
+which is latency the user hears.
+
+### Precedent
+
+Every comparable Maestro subsystem is already main-owned and renderer-projected: the Cue engine,
+the web server, Pianola's supervised loop, the plugin broker. Cadenza's HUD window
+(`src/main/app-lifecycle/cadenza-hud-window.ts`) is the closest visual analogue, and even it is a
+main-created window that buffers payloads until the renderer signals ready. Voice would be the
+odd one out.
+
+## Alternatives considered
+
+### Session in the renderer, main as a thin relay
+
+Rejected. Fastest to Phase 01 and worst for Phases 02 onward: it forces a second implementation
+for the phone, ties session lifetime to a window, and puts provider secrets and native modules on
+the wrong side of the bridge.
+
+### Session in a dedicated hidden `BrowserWindow`
+
+Considered seriously. A hidden renderer would give the session `MediaStream`, `AudioWorklet`, and
+`RTCPeerConnection` for free, which is genuinely attractive given [[adr-001-webrtc-transport]].
+
+Rejected because it trades one problem for a worse one: a hidden window is still a renderer with a
+lifecycle, a crash surface, and IPC latency between it and the dispatch authority, and it makes
+"who owns the session" ambiguous again. It also cannot be reached from the CLI or a headless
+context.
+
+The capability gap is real but narrow, and it is solvable with a Node WebRTC implementation in
+main. If that turns out to be untenable, the fallback is a hidden window acting as a **media
+endpoint only**, still driven by the main-process session, not a relocation of the session itself.
+
+### Session in a separate process
+
+Rejected as premature. It would add IPC to the dispatch path and process supervision to the
+lifecycle for isolation nobody has asked for. Revisit only if a provider proves unstable enough to
+threaten app stability.
+
+## Consequences
+
+**Positive**
+
+- One session implementation for every client, forever.
+- Sessions survive window reload, window close, and multi-window moves.
+- Providers sit next to their native modules and secrets.
+- The service is trivially testable: no DOM, no React, no Electron window. Feed it `startSession`
+ and `submitUtterance`, assert on the event stream.
+
+**Negative**
+
+- Audio device access in main needs deliberate work. The renderer's easy `getUserMedia` path is
+ not available, so Phase 01 ships mock providers and the real capture path is a later phase's
+ problem.
+- WebRTC in main requires a Node implementation rather than the browser's built-in one.
+- Every UI interaction costs an IPC hop. Acceptable: the events are small and infrequent compared
+ to the audio itself, which never crosses this boundary.
+
+**Neutral**
+
+- The renderer keeps `useVoiceInput.ts` for composer dictation until the local STT tier lands.
+ Two voice paths coexist briefly, with a clear owner for each.
+
+## Related
+
+- [[system-overview]] - the service, the tiers, and the client model.
+- [[voice-session-protocol]] - the contract every client speaks.
+- [[adr-001-webrtc-transport]] - the transport this decision makes possible.
diff --git a/docs/architecture/acappella/latency-baseline.md b/docs/architecture/acappella/latency-baseline.md
new file mode 100644
index 0000000000..21cab50c5c
--- /dev/null
+++ b/docs/architecture/acappella/latency-baseline.md
@@ -0,0 +1,224 @@
+---
+type: reference
+title: A Cappella Latency Baseline
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - latency
+ - providers
+related:
+ - '[[system-overview]]'
+ - '[[model-manager]]'
+ - '[[voice-session-protocol]]'
+ - '[[conversation-acceptance]]'
+---
+
+# A Cappella Latency Baseline
+
+Voice is the one Maestro surface where latency is not a quality-of-life detail. A user talking to
+an agent has no screen to read while they wait, so a slow turn is indistinguishable from a broken
+one. This document records what each provider configuration costs per hop, how those numbers are
+measured, and which hop to suspect first when someone reports that voice feels slow.
+
+## How a turn is measured
+
+Every turn is timed by `src/main/acappella/telemetry/turn-metrics.ts`. The zero point is the
+moment the voice activity detector decides the user stopped talking, published by the audio
+pipeline as `onSpeechEnd`. That point matters: a timer anchored on the transcript would exclude the
+decode, which is exactly the hop most often to blame.
+
+Six milestones are stamped per turn:
+
+| Span | What it measures |
+| ------------------------------- | ---------------------------------------------------------------------- |
+| **Speech end to first partial** | How long before anything at all appears. The gap the user feels most. |
+| **Final transcript** | Endpointing to a settled utterance. |
+| **Route decision** | Brain latency: which agent, which tab, which prompt. |
+| **Agent first token** | Dispatch to the agent producing text. Not ours, but it is in the turn. |
+| **First spoken sentence** | Reply text to the first audible sentence. |
+| **Total turn** | Speech end to first audible sentence. |
+
+Spans are recorded cumulatively (one subtraction per mark, which is all a hot path should do) and
+converted to per-hop deltas for display. They are formatted with `formatDuration()` from
+`src/shared/performance-metrics.ts`; there is no second duration helper.
+
+The last twenty turns are retained in memory. The most recent breakdown is readable from
+**Settings > Plugins > A Cappella > Models > Turn latency**, with a Copy button, so a bug report
+carries numbers rather than an adjective.
+
+## Configurations
+
+Four shapes, in the order a user is likely to try them.
+
+### 1. Fully local (Whisper, Qwen3, Kokoro)
+
+Audio never leaves the machine. Every hop is CPU or GPU work on the user's hardware, so the numbers
+vary by an order of magnitude across machines and this configuration is the one worth measuring on
+the oldest machine you can find rather than the newest.
+
+### 2. OpenAI STT, local Brain, ElevenLabs TTS
+
+The mixed case. Two network hops and one local inference. Usually the fastest cascade on a laptop,
+because a hosted transcription of a short utterance beats a local decode on a CPU without an
+accelerator.
+
+### 3. Fully hosted cascade (OpenAI STT, OpenAI Brain, ElevenLabs TTS)
+
+Three network hops in series. Predictable, and bounded by the slowest provider on the day.
+
+### 4. Realtime (OpenAI speech to speech)
+
+One socket, the provider's own endpointing, and no serial hops at all. The tradeoff is stated in
+the settings copy where the choice is made: the assistant speaks in that provider's voice, and the
+microphone's samples go to their servers.
+
+## Measured results
+
+**Not yet measured. This table is deliberately empty rather than filled with plausible numbers.**
+
+Recording it requires four things this phase did not have on the build machine: the three native
+runtimes installed (they are declared in `src/shared/acappella/native-runtimes.ts` but not yet in
+`package.json` dependencies, see [[packaging-notes]]), the model set downloaded, API keys for
+OpenAI and ElevenLabs, and a microphone. Inventing a baseline would be worse than having none: the
+whole purpose of this document is to be the thing a regression is measured against.
+
+| Configuration | First partial | Final | Route | Agent first token | First spoken sentence | Total |
+| ------------------------ | ------------- | ----- | ----- | ----------------- | --------------------- | ----- |
+| Fully local | - | - | - | - | - | - |
+| OpenAI STT + local Brain | - | - | - | - | - | - |
+| Fully hosted cascade | - | - | - | - | - | - |
+| Realtime | - | - | - | - | - | - |
+
+To fill it in: enable the Encore Feature, configure the slots in **Voice Providers**, speak one
+short instruction ("ask backend what changed"), then press **Read last turn** on the Models page
+and paste the copied JSON into the row. Repeat three times per configuration and record the median,
+because the first turn of a local configuration includes the model load and is not representative
+of a conversation.
+
+The by-hand pass that produces these numbers, including what to say and what to listen for on each
+check, is [[conversation-acceptance]]. Read its precondition section first: a session built without
+an `agentReplyStream` still waits for a whole reply, which measures the `buffered` arm below rather
+than the streamed one.
+
+## What each hop tells you
+
+- **First partial is slow, local STT.** The decode is CPU-bound. Check whether the machine has an
+ accelerator whisper.cpp can use, and check the partial interval: re-transcribing the whole
+ utterance every 900 ms is the design, and on a slow machine each pass takes longer than the
+ interval, so passes are skipped rather than queued.
+- **First partial is slow, hosted STT.** Network, or the utterance was long. The upload happens on
+ endpointing, so a long utterance costs upload time no partial can hide.
+- **Route decision is slow, local Brain.** Almost always a model load: the Qwen3 context unloads
+ after five idle minutes, and the next turn pays for it once. Two consecutive turns will show the
+ difference immediately.
+- **Route decision is slow, hosted Brain.** Look for a retry. The transport backs off on 429 and
+ 5xx up to three attempts, which can add seconds; the `provider-quota-exceeded` session error
+ fires only after the last attempt.
+- **First spoken sentence is slow.** The TTS provider is synthesising the whole first sentence
+ before any audio exists. Both real providers work sentence by sentence for exactly this reason,
+ so a long first sentence is the usual cause.
+- **Total is fine but it FEELS slow.** Check barge-in rather than latency. Cutting the assistant
+ off has to be instant, and a cancel that waits out a sentence reads as lag even when every number
+ above is good.
+
+## Time to first spoken word
+
+The number a user actually feels is not the total turn: it is how long they stand in silence before
+anything is said. Everything in `src/main/acappella/speech/` exists to shorten that one span, and it
+is measured as `speak-sentence` index 0 minus the detector's speech end, which is the
+**First spoken sentence** column above.
+
+Three mechanisms move it, in descending order of effect:
+
+1. **The agent output tap cuts at a completed thought, not at the end of the reply.** A four hundred
+ line summary is spoken from its first paragraph while the agent is still writing the rest. This
+ is worth more than every other optimisation combined, because it removes the agent's own write
+ time from the span rather than shaving a hop.
+2. **The translator rewrites that piece alone.** One short rewrite instead of one long one, and a
+ reply that is already conversational ("yes, the tests pass") skips the model entirely.
+ `ConversationalTranslator.stats` reports the translations-to-passthroughs split, which is the
+ number to check when short answers feel slower than they should.
+3. **The scheduler synthesizes one sentence ahead of the one being delivered.** This does not shorten
+ the first word; it removes the provider round trip that would otherwise fall between every pair
+ of sentences. A reply that starts fast and then stutters is this, not the tap.
+
+### Measurements
+
+Two instruments, because they answer different questions and neither replaces the other.
+
+**The harness** (`npm run acappella:latency`) measures the span this layer owns, with the providers
+replaced by stubs whose costs are declared in the script. It exists because a microphone session
+measures four things at once - the decode, the model on the day, the network on the day, and the
+streaming layer - and only the last of those is ours to regress. Everything between the stubs is the
+shipping code: `AgentOutputTap`, `ConversationalTranslator`, `SpeechScheduler`, and the splitter.
+Each fixture runs twice: `streamed` is the shipped path, `buffered` is the counterfactual the layer
+replaced (wait for the whole reply, rewrite the whole thing, then speak). The agent writes at the
+same rate in both arms, so the difference between them is the tap and nothing else.
+
+Recorded 2026-08-15, one run per cell, zero point the agent's first token. **First sound** and
+**first word of the answer** are different numbers on a long reply: the buffered arm makes a noise at
+twenty seconds because the tap refuses to go silent and says the agent is still working, which is the
+safety net firing rather than an answer arriving.
+
+| Profile | Fixture | Arm | First sound | First word of the answer | Longest mid-turn silence |
+| ------- | ------------ | -------- | ----------- | ------------------------ | ------------------------ |
+| local | long summary | streamed | 220 ms | 220 ms | 0 ms |
+| local | long summary | buffered | 20221 ms | 33734 ms | 11227 ms |
+| local | diff-heavy | streamed | 226 ms | 226 ms | 11903 ms |
+| local | diff-heavy | buffered | 14248 ms | 14248 ms | 0 ms |
+| local | confirmation | streamed | 206 ms | 206 ms | 0 ms |
+| local | confirmation | buffered | 206 ms | 206 ms | 0 ms |
+| hosted | long summary | streamed | 280 ms | 280 ms | 0 ms |
+| hosted | long summary | buffered | 20283 ms | 33795 ms | 11226 ms |
+| hosted | diff-heavy | streamed | 284 ms | 284 ms | 12061 ms |
+| hosted | diff-heavy | buffered | 14307 ms | 14307 ms | 0 ms |
+| hosted | confirmation | streamed | 271 ms | 271 ms | 0 ms |
+| hosted | confirmation | buffered | 275 ms | 275 ms | 0 ms |
+
+Three things to read out of it:
+
+- **The tap is worth 33 seconds on a long reply and 14 on a diff-heavy one.** That is the whole
+ argument for the layer, and it is not a hop that was shaved - it is the agent's own write time
+ removed from the span. Both profiles land within 60 ms of each other on the streamed arm, which is
+ the point: once the first spoken word costs one short rewrite, the choice of provider stops
+ mattering to the number the user feels.
+- **A one-line confirmation is identical in both arms.** No model hop, because the passthrough test
+ catches it. If that row ever shows the streamed arm slower, the passthrough stopped firing.
+- **The diff-heavy streamed row has a 12 second silence in the middle of the turn.** The intro line
+ is spoken at 226 ms, the fence is correctly never spoken, and nothing is said again until the
+ closing prose. The hang notice does not cover it, because the diff keeps arriving as `data` and
+ keeps resetting the timer. Nothing here is behaving incorrectly, and it is still the worst listening
+ experience the harness produces - the thing to watch if a user reports the assistant "stopping
+ halfway".
+
+**The real pipeline**, which the harness deliberately does not stand in for. Record the median of
+three turns per configuration with one short instruction ("ask backend what changed"), taken from
+**Settings > Plugins > A Cappella > Models > Turn latency**. Zero point is the detector's speech end,
+so these include the decode and the route that the harness excludes.
+
+| Configuration | Time to first spoken word | Inter-sentence gap | Notes |
+| -------------------- | ------------------------- | ------------------ | ----- |
+| Fully local cascade | - | - | - |
+| Fully hosted cascade | - | - | - |
+| Realtime | - | - | - |
+
+The realtime pipeline bypasses this layer: its provider produces speech directly, so the tap and the
+translator do not run and the span is the provider's own. That is the comparison the table exists to
+make - when the cascade's time to first spoken word is within a couple of hundred milliseconds of
+realtime, the streaming layer is doing its job and there is no reason to send audio to a third party
+for speed alone. It is also why the harness has no realtime arm: there would be nothing of ours in it.
+
+### Barge-in
+
+Measured separately, from the detector's `speech-start` to playback going quiet. The teardown is
+ordered so the ducking is first (see `speech/barge-in.ts`), which puts the number the user perceives
+at roughly the duck ramp, about 20 ms, regardless of how long the cancellation behind it takes. The
+guard window (250 ms after speech starts) is deliberately excluded: it is the one span where a
+barge-in is refused on purpose.
+
+## Related
+
+- [[system-overview]] - the two pipeline shapes and the provider resolution rules.
+- [[model-manager]] - what the local tier downloads and how readiness is decided.
+- [[packaging-notes]] - the native runtimes the local tier needs.
diff --git a/docs/architecture/acappella/model-manager.md b/docs/architecture/acappella/model-manager.md
new file mode 100644
index 0000000000..81fca61055
--- /dev/null
+++ b/docs/architecture/acappella/model-manager.md
@@ -0,0 +1,135 @@
+---
+type: reference
+title: A Cappella Model Manager
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - models
+related:
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+---
+
+# A Cappella Model Manager
+
+A Cappella is the first Maestro feature that ships a binary payload the user has to fetch. This
+document describes how that stays honest: what is downloaded, when, from where, and how the app
+proves the bytes are what it promised.
+
+## The four rules
+
+1. **Enabling the Encore Feature touches the network never.** Registering the model IPC handlers
+ constructs nothing and opens no socket. `models:list` is a disk read against a frozen local
+ catalog. The first byte of traffic in the whole subsystem comes from a `models:download` the
+ user pressed a button to send.
+2. **Pinned revisions, never `main`.** Every source URL is `/resolve/<40-hex commit>/`. A moving
+ ref would mean the bytes behind a hash can change under us, which turns SHA-256 verification
+ into a superstition.
+3. **A file appears at its final path only after its hash matched.** Until then the bytes live in
+ `.part`. A killed app therefore leaves a resumable partial, never a truncated file that
+ passes an existence check and detonates weeks later inside a model runtime.
+4. **No silent substitution, ever.** When a required model is missing or corrupt, voice mode
+ refuses to start and says which piece is missing and what to do about it. It does not reach for
+ a cloud provider the user did not choose: that is both an unasked-for charge and a privacy
+ break.
+
+## Files
+
+| File | Responsibility |
+| ----------------------------------------------- | ---------------------------------------------------------------------------------- |
+| `src/shared/acappella/model-catalog.ts` | The frozen bill of materials: id, revision, per-file URL + SHA-256 + size, license |
+| `src/shared/acappella/readiness.ts` | Readiness shapes, shared with the renderer |
+| `src/main/acappella/models/model-store.ts` | Install layout, manifests, `isInstalled`, `verify`, `remove`, `totalFootprint` |
+| `src/main/acappella/models/model-downloader.ts` | Resumable range downloads, streaming SHA-256, pause/resume/cancel, progress |
+| `src/main/acappella/models/capability-gate.ts` | `resolveVoiceReadiness()`: which slots are satisfied, and why not |
+| `src/main/ipc/handlers/acappella-models.ts` | The `models:*` channels and the `models:progress` broadcast |
+| `src/renderer/components/Settings/ACappella/` | Voice Setup and the Models page |
+
+## The catalog
+
+Four models, all fetched from Hugging Face at a pinned commit:
+
+| Model | Role | Source | License | Size |
+| -------------------------- | --------- | ---------------------------------------------- | ---------- | -------- |
+| `whisper-base-en` | STT | `ggerganov/whisper.cpp@5359861` | MIT | 141.1 MB |
+| `openwakeword-base` | Wake word | `littlebearlabs/openwakeword-features@5e032d9` | Apache-2.0 | 2.3 MB |
+| `kokoro-82m` | TTS | `onnx-community/Kokoro-82M-v1.0-ONNX@1939ad2` | Apache-2.0 | 311.0 MB |
+| `qwen3-1.7b-instruct-q4km` | Brain | `unsloth/Qwen3-1.7B-GGUF@d7f544e` | Apache-2.0 | 1.0 GB |
+
+Every hash is the Hugging Face LFS object id, which IS the SHA-256 of the file contents, read from
+`/api/models//paths-info/` at the pinned commit. Do not hand-edit one.
+
+**A model is one or more FILES.** Two of the four genuinely need more than one: the wake word needs
+its mel front end and its embedding head, and Kokoro needs a voice pack alongside the graph.
+`sourceUrl` / `sha256` / `bytes` therefore live per file; the entry carries the computed total, and
+`MODEL_SETS` totals are computed from those. No size string is written by hand anywhere, so a
+revision bump cannot leave the UI quoting a stale number.
+
+`MODEL_SETS` names two bundles: `hands-free-local` (STT + wake word + TTS) and `fully-local` (that
+plus the Brain).
+
+## Install layout
+
+```
+userData/models/acappella//
+ manifest.json id, revision, sha256, bytes, sourceUrl, license, installedAt, verifiedAt
+ the model itself
+```
+
+`isInstalled(id)` requires a manifest whose digest matches the current catalog AND every file's
+byte length on disk to match exactly. It is never a bare `existsSync`, because a truncated file
+exists. `verify(id)` is the slower check: it re-hashes and reports a mismatch as CORRUPT with both
+hashes, and repairs nothing. Silently re-downloading would spend a gigabyte without asking and hide
+the fact that something on this machine is modifying model files.
+
+Manifests are written through `atomicWriteJson` plus a per-model write queue
+(`src/main/utils/atomic-json-store.ts`). Concurrent non-atomic writes have corrupted JSON state in
+this codebase before.
+
+## Download lifecycle
+
+- Resume offset is the `.part` file's length, sent as `Range: bytes=N-`. A server that answers 200
+ instead of 206 ignored the range, so the partial is discarded rather than appended to.
+- SHA-256 is computed as bytes stream past; on resume the existing partial is re-hashed first so
+ the digest covers the whole file.
+- A mismatch deletes the `.part` and reports both hashes. Keeping it would resume a file already
+ known to be wrong, forever.
+- Pause keeps the partial; cancel deletes the model directory. Both leave on-disk state coherent,
+ and neither writes a manifest, because there is no manifest until success.
+- Transient network errors retry with exponential backoff. A 404 or a hash mismatch does not.
+- At most two models transfer at once: a 1.4 GB set downloaded four-wide saturates a domestic
+ uplink and makes every file slower, which reads to the user as a hang.
+- Progress is throttled at the source (~4 Hz) and broadcast on `models:progress`. The renderer adds
+ a second `useThrottledCallback` stage for its own repaints.
+
+## The capability gate
+
+`resolveVoiceReadiness(settings)` returns a structured verdict rather than a boolean: per slot
+(STT, TTS, Brain, wake word) either satisfied, or a reason plus a suggested action. The reasons are
+closed: `model-not-installed`, `model-corrupt`, `api-key-missing`, `provider-unreachable`.
+
+`VoiceSessionService.startSession()` consults it BEFORE opening the microphone, and on failure
+emits `session-error(provider-unavailable)` naming the missing piece and its recovery. The gate has
+no code path that can return a different provider than the one configured; choosing providers is
+the registry's job, and the registry's only fallback is the mock.
+
+**The wake word does not gate a session.** Hands-free means something is always listening, and that
+is a real capability with a real requirement. Click-to-talk is not, so refusing a session the user
+explicitly asked for because an optional always-on model is missing would be the gate getting in
+the way. `canStartSession` and `canRunHandsFree` are reported separately.
+
+## Disk lifecycle
+
+`totalFootprint()` walks the models root rather than the catalog, so a directory left behind by a
+model since dropped from the catalog is still counted and still reclaimable. Disk the user cannot
+see is disk they cannot get back.
+
+`models:remove`, `models:remove-all`, and `models:footprint` stay callable when the Encore Feature
+is OFF, following the `stop-session` precedent. The Models page offers to reclaim the space exactly
+then, with a confirmation step, and deletes only `userData/models/acappella`.
+
+## Related
+
+- [[system-overview]] - the provider tiers and the session pipeline these models plug into.
+- [[voice-session-protocol]] - every event, payload, and direction.
diff --git a/docs/architecture/acappella/packaging-notes.md b/docs/architecture/acappella/packaging-notes.md
new file mode 100644
index 0000000000..3765f19038
--- /dev/null
+++ b/docs/architecture/acappella/packaging-notes.md
@@ -0,0 +1,112 @@
+---
+type: reference
+title: A Cappella Packaging, Signing, and Permissions
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - packaging
+ - notarization
+ - permissions
+related:
+ - '[[system-overview]]'
+ - '[[model-manager]]'
+---
+
+# A Cappella Packaging, Signing, and Permissions
+
+Local inference means native binaries, and native binaries in an Electron app mean code signing, hardened runtime entitlements, notarization, and three separate platform stories. This page is the record of what was decided, what was verified, and what is still open, so the next person cutting a release does not rediscover it from a crash report.
+
+The load-bearing fact: every failure in this area is invisible in development. A native module left inside `app.asar`, an unsigned nested dylib, a missing per-platform prebuild - all of them work from source and fail only in the installed, signed app, on someone else's machine, after release.
+
+## The runtime registry is the single source of truth
+
+`src/shared/acappella/native-runtimes.ts` holds one descriptor per native runtime: the npm package, an exact version pin, the per-platform prebuild story, the `asarUnpack` globs, and the binaries a packaged app must contain. Four consumers read it and none of them keep their own copy:
+
+| Consumer | What it uses the registry for |
+| ------------------------------------------------ | --------------------------------------------------------------- |
+| `src/main/acappella/runtime/native-loader.ts` | The only module allowed to import these packages |
+| `src/main/acappella/runtime/runtime-selftest.ts` | "Run voice self-test" on the Models page |
+| `scripts/verify-native-packaging.mjs` | Post-packaging assertion, reads the compiled copy from `dist/` |
+| `src/main/acappella/models/capability-gate.ts` | Reports a runtime that will not load as its own blocking reason |
+
+`src/__tests__/shared/acappella-native-runtimes.test.ts` asserts the registry against `package.json`: version pins are exact, every `asarUnpack` glob is present in the electron-builder config, and `declared` matches the actual dependency list.
+
+## The three runtimes
+
+| Runtime | Package | Version | Slots | Prebuilds | Electron rebuild |
+| ------------ | ------------------ | ------- | --------------- | ----------------------------------------------------- | ---------------- |
+| llama.cpp | `node-llama-cpp` | 3.20.0 | Conductor Brain | Prebuilt for all four targets via `@node-llama-cpp/*` | No |
+| whisper.cpp | `smart-whisper` | 0.8.1 | Speech-to-Text | **None. Compiles from source at install** | No |
+| ONNX Runtime | `onnxruntime-node` | 1.27.0 | TTS + wake word | Prebuilt, `bin/napi-v6///` | No |
+
+None of the three needs `electron-rebuild`. All three are Node-API addons, and Node-API is ABI-stable across Node and Electron by design, which is why they are absent from the `postinstall` rebuild list that carries `node-pty` and `better-sqlite3`. Adding a non-Node-API addon later means setting `requiresElectronRebuild: true` AND adding it to that list; the registry test fails if the two disagree.
+
+### Open question: whisper has no prebuilds
+
+`smart-whisper` runs `node-gyp rebuild` in its install script on every platform. That makes a C++ toolchain and CMake a build requirement for every contributor and both CI legs, not just for release machines. It is recorded here rather than worked around because the decision belongs with the phase that first executes the runtime:
+
+- Accept the source build and add the toolchain to CI, or
+- Produce prebuilds ourselves and consume them, or
+- Choose a different whisper.cpp binding, or run STT through ONNX instead and change the model catalog entry (the catalog currently pins `ggml-base.en.bin`, which is a whisper.cpp format).
+
+### Deliberately not yet dependencies
+
+All three descriptors carry `declared: false`, and none of the packages is in `package.json` dependencies yet. They land in the phase that first executes them (Phase 05, the real providers), and `declared` flips in that same commit.
+
+The reason is cost with no benefit: these packages are large, one of them compiles from source, and until a provider calls them, adding them would slow every `npm ci` and both CI legs to install code nothing runs. The loader reports `not-a-dependency`, which is a distinct and truthful answer from "your install is broken", the self-test reports `skipped` rather than `fail`, and the packaging script skips them unless run with `--require-all`. The packaging configuration (asarUnpack globs, entitlements, Info.plist, the assertion script) is already in place, so the phase that adds the dependencies changes one boolean per runtime and one dependency line, not the build.
+
+## macOS: entitlements, Info.plist, notarization
+
+`build/entitlements.mac.plist` gained `com.apple.security.device.audio-input` (it was present but set to `false`, which denies capture under the hardened runtime with no prompt shown - a session that starts and stays silent forever).
+
+`com.apple.security.cs.allow-jit`, `allow-unsigned-executable-memory`, and `disable-library-validation` were already enabled for reasons that predate A Cappella. They were NOT added for the native runtimes and should not be justified by them; each one weakens the app, and any future addition needs a runtime that provably requires it.
+
+`NSMicrophoneUsageDescription` is set through `build.mac.extendInfo` in `package.json`. It names A Cappella specifically and states that audio is processed on the machine when local providers are selected. A registry test asserts both properties, because "Maestro would like to access the microphone" answers neither question a user has at the moment of the prompt.
+
+Every nested binary must be signed with the same identity: notarization rejects a bundle containing an unsigned nested binary, and `node-llama-cpp` ships several ggml dylibs beside its addon. `scripts/verify-native-packaging.mjs` runs `codesign --verify --strict` on each expected binary rather than a single `--deep` pass, because `--deep` stops at the first failure and the useful output is the full list.
+
+### Verification, and what has not been run
+
+Automated, and wired into `npm run package:mac` / `package:win` / `package:linux`:
+
+```
+npm run verify:native-packaging # after any electron-builder target
+node scripts/verify-native-packaging.mjs --require-all # release builds, once the runtimes ship
+```
+
+**A real notarized build has not been run for this phase.** The signing identity and Apple credentials are not available in this environment, so `spctl --assess` and `codesign --verify --deep --strict` against a stapled artifact remain to be done on a machine that has them, along with installing the result on a machine that has never run Maestro from source. Since no native runtime is a dependency yet, that build would exercise the entitlement and Info.plist changes but not the nested-binary signing path, which is the part worth proving. The honest sequencing is to run it in Phase 05, when there is a dylib in the bundle to sign.
+
+## Windows
+
+- The prebuilt binaries load from the installed location once they are unpacked from the asar, which is what the `asarUnpack` entries and the packaging assertion enforce.
+- Paths with spaces and non-ASCII characters: the loader never builds a path. It hands a bare package specifier to the module system, so resolution is Node's, which handles both. Model files are a separate matter and are already handled by the model store.
+- No Visual C++ redistributable is expected: Electron ships the CRT the renderer needs, and all three runtimes are Node-API addons built against it. If one is missing anyway, the loader detects the OS's "The specified module could not be found" (Windows error 126) and reports it as a distinct load failure that names the redistributable, which reaches the user through the capability gate instead of reading like a corrupt install.
+
+## Linux
+
+- AppImage and deb both extract to a real filesystem path before launch, so the unpacked binaries are dlopen-able for the same reason they are on the other platforms.
+- PulseAudio and PipeWire are both reached through Chromium's audio stack in the hidden audio host window, not directly, so there is nothing platform-specific in A Cappella's own code.
+- Linux has no microphone permission API and no privacy-pane deep link that works across desktops. `micSettingsUrl()` returns null there and `getMicPermission()` reports `unknown` until a capture actually fails.
+
+Neither the AppImage nor the deb has been verified with a real capture in this phase; both are listed above as what to run when the runtimes land.
+
+## Platform branching
+
+Main-process code uses `isWindows()`, `isMacOS()`, `isLinux()` from `src/shared/platformDetection.ts`. Renderer code must never read `process.platform`: the renderer's `process` shim reports the sentinel `'browser'`, so `platformDetection` rejects it and renderer code uses `platformUtils` instead.
+
+## The microphone permission is not a model problem
+
+`src/main/acappella/permissions/mic-permission.ts` answers one question, and the capability gate turns it into its own slot with its own reason codes (`mic-permission-denied`, `mic-permission-restricted`). "Voice unavailable" in front of someone who has already downloaded 1.4 GB of models, when the real problem is a TCC checkbox, is a support ticket the app could have answered itself.
+
+Four states are kept apart because the recovery differs for each: `not-determined` (nobody has asked, blocks nothing), `granted`, `denied` (one checkbox), `restricted` (policy, and the user cannot fix it, so no privacy-pane button is offered).
+
+**When the prompt happens.** At the first real session start, in the `acappella:start-session` handler. Not at app launch, and not when the Encore Feature is switched on. `getMicPermission()` is a pure query and never prompts, which is what makes it safe for the capability gate to call on every Settings render.
+
+**Why a remembered denial is not sticky.** Windows and Linux learn about a denial only from a failed capture. That observation fills the gap where the OS has no answer, but the OS wins wherever it has one, and a fresh session start clears it. The alternative deadlocks: a denial that outranks a `granted` query, or that survives the user fixing the setting, blocks every future session through the gate, and the only thing that could clear it is the successful capture the gate is now preventing.
+
+## The self-test
+
+`Settings > Plugins > A Cappella > Models > Run voice self-test` loads each runtime through the same loader the providers use, runs a trivial operation against its API, and reports per-runtime pass/fail with timings plus the microphone permission. It loads no model and opens no device, so it is free to run on a machine where nothing has been downloaded. The result is also collected into the debug package as `voice-runtime.json`, so a support report carries it without anyone having to ask.
+
+A probe checks the export the provider will actually call (`getLlama`, `Whisper`, `InferenceSession`), so a version bump that moves the API fails here rather than mid-session. Every probe races a timeout, because "the button did nothing" is the bug being diagnosed and a diagnostic that reproduces it is not a diagnostic.
diff --git a/docs/architecture/acappella/routing-evaluation.md b/docs/architecture/acappella/routing-evaluation.md
new file mode 100644
index 0000000000..d994e6cc0d
--- /dev/null
+++ b/docs/architecture/acappella/routing-evaluation.md
@@ -0,0 +1,214 @@
+---
+type: reference
+title: A Cappella Routing Evaluation
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - routing
+ - conductor
+related:
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+---
+
+# A Cappella Routing Evaluation
+
+Routing is the only part of A Cappella that can be confidently wrong. Every other failure announces
+itself: a dead microphone produces no transcript, a missing model refuses to start, a broken voice
+says nothing. A misroute produces a completed turn, a spoken confirmation, and a prompt sitting in
+the wrong repository. So routing quality has to be a measured number rather than a feeling, and this
+document is where that number lives.
+
+## What is measured, and where
+
+Three separate things get called "routing quality", and they are measured in three different places.
+Conflating them is how a router with a 60% hit rate ends up described as working.
+
+| Layer | Question it answers | Where it is measured |
+| ----------------- | ---------------------------------------------------------- | -------------------------------------- |
+| Decision rules | Given a decision, does the router do the right thing? | `src/__tests__/main/acappella/router/` |
+| Model in the loop | Given an utterance, does the Brain pick the right target? | The script below, run by hand |
+| Field | Over real use, how often does the user have to correct it? | The routing log (`routingQuality()`) |
+
+The first is deterministic and runs in CI. The second needs a real model and a real roster. The
+third accumulates on its own once people are talking to it.
+
+## The routing log is the field instrument
+
+`src/main/acappella/router/routing-log.ts` records every turn: the utterance, the serialized size of
+the context the Brain saw, the decision, the confidence, the latency, and what became of it. The
+outcome is what makes the number honest:
+
+- `dispatched` - it landed, and the user let it stand.
+- `corrected` - it landed and the user moved it. **A miss**, even though nothing errored.
+- `clarified` - the router asked instead of guessing. **Neither**, and excluded from the hit rate:
+ asking is the correct behaviour below the confidence threshold, and counting it either way makes
+ the threshold impossible to tune.
+- `failed` - the dispatch could not be performed at all.
+
+`hitRate = dispatched / (dispatched + corrected)`. Read it with
+`window.maestro.voice.routingLog()`, or from the `acappella:routing-log` IPC channel.
+
+## The evaluation script
+
+Fifteen utterances against a fixed roster of four agents and twelve tabs. The set is deliberately
+weighted toward the cases that are ambiguous rather than the ones that are obvious: a script of
+fifteen "tell the backend agent to run the tests" would score 100% and prove nothing.
+
+It runs headless. `scripts/acappella-routing-eval.ts` encodes the roster and the script below and
+drives the real `createConductorRouter` against a real Brain:
+
+```bash
+npm run acappella:eval # the Conductor-agent Brain
+npm run acappella:eval -- --brain anthropic # ANTHROPIC_API_KEY
+npm run acappella:eval -- --brain openai # OPENAI_API_KEY
+npm run acappella:eval -- --brain local --model-path /path/to/qwen3-1.7b.gguf
+```
+
+This was originally written down as a microphone session with four live agents, which is the wrong
+instrument for the thing being measured. Routing takes a TRANSCRIPT and a ROSTER, both of which are
+data; speaking the script aloud adds speech recognition and four real agents as uncontrolled
+variables and makes the result unrepeatable. Everything below the Brain in the harness is shipping
+code - the prompt from `src/prompts/acappella-router.md` (read through `initializePrompts()`, so a
+local edit is what gets measured), `parseRouteDecision`, the grammar validator, the recall ranker,
+the confidence and recall policies, and the routing log itself - so the only thing being varied is
+the model. The harness plays the user who corrects a misroute: a decision that misses its
+expectation is marked `corrected` in the log, exactly as the HUD's correction control does, so
+`routingQuality()` produces the reported number rather than a second tally beside it.
+
+An unusable Brain fails once, before the script starts, rather than fifteen times inside the results
+table. That is how the local tier reports itself in a checkout without the native runtime.
+
+### The fixture roster
+
+| Agent | Type | Project path | Tabs |
+| ---------- | ----------- | -------------------- | ------------------------------------------------------------------ |
+| `Backend` | claude-code | `/repo/payments-api` | Auth Refactor (active), DB Migrations, Rate Limit Spike (snoozed) |
+| `API` | codex | `/repo/gateway` | Gateway Routing (active), Webhook Retries, Old Auth Spike (closed) |
+| `Frontend` | claude-code | `/repo/web` | Sidebar Collapse (active), Checkout Flow, Dark Mode |
+| `Infra` | opencode | `/repo/terraform` | Cluster Upgrade (active), Cost Report, Log Retention |
+
+### The utterances
+
+| # | Utterance | Expected target | Expected action | Tests |
+| --- | ------------------------------------------------ | --------------- | ------------------ | ------------------------ |
+| 1 | "run the tests" | active agent | `current` | same-topic continuation |
+| 2 | "what broke" | active agent | `current` | pronoun-free follow-up |
+| 3 | "add a rate limiter to the public endpoints" | Backend | `new` | topic switch |
+| 4 | "ask the frontend agent about the checkout flow" | Frontend | `recall` | explicit agent naming |
+| 5 | "tell infra to bump the cluster version" | Infra | `current` | explicit agent naming |
+| 6 | "back to the auth thing" | Backend | `recall` | vague recall |
+| 7 | "what did we decide about webhook retries" | API | `recall` | recall by topic |
+| 8 | "the gateway one" | API | `current` | recall by project path |
+| 9 | "pick up that rate limit spike again" | Backend | `recall` (snoozed) | snoozed-tab wake |
+| 10 | "go back to the old auth spike" | API | `recall` (closed) | closed-tab reopen offer |
+| 11 | "how many agents do I have running" | conductor | - | Maestro-level question |
+| 12 | "which one is busy right now" | conductor | - | fleet-level question |
+| 13 | "make the dark mode toggle stick" | Frontend | `recall` | topic match over recency |
+| 14 | "do the auth one" | ambiguous | clarification | low confidence |
+| 15 | "no, the other one" | - | correction | correction path |
+
+Utterance 14 is the interesting one: with an "Auth Refactor" tab on Backend and an "Old Auth Spike"
+tab on API, the correct behaviour is a spoken "Backend or API?" rather than a coin flip. Which agent
+the router leaned toward while asking is deliberately NOT scored - when it is right to be unsure,
+penalising the lean would penalise the behaviour the threshold exists to produce. The harness then
+routes the answer ("the backend one") with `clarification` set, which is the round trip that stops a
+two-word reply from becoming a tab called "the backend one". Utterance 15 is not routed at all: it is
+recognised from the utterance alone and turned into a correction plan.
+
+Fourteen of the fifteen are routed and scored. Utterance 15 is a recognition check, and the
+disambiguation answer is reported beside the script rather than inside it.
+
+Record each run as a new row below, with the date and the Brain, so a prompt change or a model swap
+can be compared against what came before rather than argued about.
+
+## Results
+
+### Deterministic layer
+
+Green as of 2026-08-15, inside a whole-repo run of 37,762 tests. 102 assertions across
+`grammar.test.ts`, `conductor-router.test.ts`, `tab-recall.test.ts`, `routing-context.test.ts`,
+`routing-log.test.ts` and `conductor-agent.test.ts`, plus the executor and session-service suites.
+This layer proves the router's rules, not the model's judgement: every case in it is driven by a
+scripted Brain, so a 100% pass says the decision handling is correct and says nothing about whether a
+real model would have produced those decisions.
+
+### Model in the loop
+
+| Date | Brain | Hits | Corrections | Clarifications | Hit rate | Mean latency |
+| ---------- | ----------------- | ----- | ----------- | -------------- | -------- | ------------ |
+| 2026-08-15 | `conductor-agent` | 10/14 | 3 | 2 | 77% | 5485 ms |
+| 2026-08-15 | `conductor-agent` | 11/14 | 2 | 2 | 85% | 6703 ms |
+| 2026-08-15 | `conductor-agent` | 10/14 | 3 | 2 | 77% | 5854 ms |
+
+Three runs of the Conductor-agent Brain (Claude Code, `--output-format json`, read-only). "Hits" is
+`dispatched` from the routing log; the script matched 11, 12 and 11 of its fourteen expectations, and
+the two counts differ because a correct clarification is a hit for the script and neither for the hit
+rate.
+
+Two misses are reproducible across all three runs, and they are the reason for running this at all:
+
+- **"add a rate limiter to the public endpoints"** (expected `new`) recalls the snoozed
+ `Rate Limit Spike` tab every time, at 0.60. The agent is right; the tab is not. A NEW request that
+ shares words with an abandoned conversation is currently pulled into it, because nothing in the
+ prompt says that a tab's topic being ABOUT a subject is weaker evidence than the utterance being a
+ fresh instruction. This is the most useful finding here and it is a prompt fix, not a code fix.
+- **"the gateway one"** (expected a confident `current` on API) lands on API but asks, at 0.40. The
+ target is right every time and only the confidence is under the threshold. Defensible behaviour for
+ a three-word fragment, and arguably the script's expectation is the thing that is wrong; recorded
+ rather than tuned away, because a threshold moved to make a table look better is a threshold that
+ is no longer measuring anything.
+
+One miss is a threshold flake: "ask the frontend agent about the checkout flow" scored 0.50 on the
+third run and 0.90 on the other two, so it asked once for an utterance that names its agent out loud.
+
+**Latency is the headline.** 5.5 to 6.7 seconds mean, with individual turns to 20 s. That is an order
+of magnitude outside the routing budget in [[latency-baseline]], and it is the expected cost of this
+tier: a full agent run is being paid for a classification. The Conductor-agent Brain is for people who
+want routing that reasons about their projects and will accept the wait for it. Voice-paced routing is
+the local and hosted tiers, and their numbers belong in the table above before any claim about
+A Cappella's felt latency is made.
+
+**Not yet measured: the local and hosted tiers, and shape parity between them.** Both fail closed
+here and say so:
+
+```
+Routing evaluation failed: Qwen3 1.7B (local) is not usable here:
+ llama.cpp (Conductor Brain) is not part of this build yet.
+Routing evaluation failed: Anthropic (hosted) is not usable here:
+ No Anthropic API key is configured.
+```
+
+`node-llama-cpp` is loaded dynamically and is not installed in this checkout, and no hosted key is
+configured. Filling those rows in needs the native runtime plus the Qwen3 1.7B GGUF for the first and
+one API key for the second; the harness needs nothing else, and each run is about ninety seconds.
+What to look for when they are run:
+
+1. **Shape parity.** The local grammar-constrained Brain and a hosted Brain must produce the same
+ decision SHAPE for the same input - the same `target`, `tabAction`, and `tabId` - even where their
+ confidences differ. Divergence here means the prompt reads differently to the two models, and that
+ is a prompt bug rather than a model difference. Run both with `--json` and diff the `results`
+ arrays.
+2. **Latency per Brain.** Routing sits between a finished sentence and anything visible happening, so
+ it is felt directly. The routing log records `latencyMs` per turn; `routingQuality().meanLatencyMs`
+ aggregates it. Compare against the per-hop budget in [[latency-baseline]].
+
+A caveat that belongs on every row: the Conductor-agent Brain is not deterministic, so a single run is
+a sample rather than a score. Three runs moved between 77% and 85% on fourteen utterances with no code
+change between them. Read a one-row difference as noise; read a reproducible per-utterance miss, like
+the rate-limiter one above, as a finding.
+
+## Tuning
+
+`DEFAULT_CONFIDENCE_THRESHOLD` in `src/main/acappella/router/conductor-router.ts` is 0.55. It is the
+one number worth moving once there is field data, and the log is arranged to make that decidable:
+
+- Many `corrected` entries with a confidence above the threshold means the threshold is too low - the
+ router is acting on beliefs it should be checking.
+- Many `clarified` entries the user answers with the router's own first guess means it is too high -
+ it is asking questions whose answer it already had.
+
+The routing prompt itself is `src/prompts/acappella-router.md`, editable in Settings > Maestro
+Prompts. Someone whose agents are all called "api" can teach the Conductor how to tell them apart
+without touching the code, and the log is how they find out whether it worked.
diff --git a/docs/architecture/acappella/system-overview.md b/docs/architecture/acappella/system-overview.md
new file mode 100644
index 0000000000..8da5aff4ba
--- /dev/null
+++ b/docs/architecture/acappella/system-overview.md
@@ -0,0 +1,287 @@
+---
+type: architecture
+title: A Cappella System Overview
+created: 2026-08-14
+tags:
+ - voice
+ - architecture
+ - acappella
+related:
+ - '[[voice-session-protocol]]'
+ - '[[adr-001-webrtc-transport]]'
+ - '[[adr-002-main-process-session]]'
+ - '[[transport-and-pairing]]'
+---
+
+# A Cappella System Overview
+
+A Cappella turns Maestro into something you talk to. You say "start a new tab on the backend
+agent about the auth refactor" and a correctly named tab appears on the right agent, primed with
+the prompt. The agent answers, and the answer is spoken back in conversational form rather than
+read out as raw terminal output.
+
+This document describes the skeleton that Phase 01 lands. Everything real that arrives later
+(Whisper, Kokoro, OpenAI Realtime, ElevenLabs, the iPhone leg) drops in behind the interfaces
+named here.
+
+## The one architectural rule
+
+**The voice session lives in the MAIN process and is transport-agnostic.**
+
+The desktop renderer is a client of the session. The iPhone will be a second client on the same
+protocol, not a port of the desktop UI. Nothing in the session service may reference a
+`BrowserWindow`, a React store, or the DOM. The rationale is recorded in
+[[adr-002-main-process-session]].
+
+```mermaid
+flowchart LR
+ subgraph clients[Clients]
+ R[Desktop renderer
Voice HUD]
+ P[iPhone
WebRTC peer]
+ C[maestro-cli
future]
+ end
+ subgraph main[Main process]
+ S[VoiceSessionService
state machine + seq]
+ X[route-executor]
+ PR[provider registry]
+ end
+ subgraph prov[Providers]
+ STT[SttProvider]
+ TTS[TtsProvider]
+ B[BrainProvider]
+ end
+ R <-->|IPC acappella:*| S
+ P <-->|WS signaling + WebRTC media| S
+ C <-->|IPC/WS| S
+ S --> PR
+ PR --> STT
+ PR --> TTS
+ PR --> B
+ S --> X
+ X -->|remote:* to the renderer| R
+```
+
+## Surveyed precedent
+
+Phase 01 is built on top of code that already exists. The survey below records what was read and
+what each piece contributes.
+
+| Existing code | What it contributes |
+| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/renderer/hooks/utils/useVoiceInput.ts` | Web Speech dictation for the AI composer on touch devices. It appends a transcript to a draft; it has no session, no routing, and no speech out. A Cappella eventually subsumes it, but it stays until the local STT tier lands. |
+| `src/main/global-hotkey-manager.ts` | A singleton owning exactly one system-wide accelerator (`setGlobalShowHotkey`). Push-to-talk needs a second binding, so Phase 06 generalizes this from a singleton into a registry. |
+| `src/main/app-lifecycle/cadenza-hud-window.ts` | The precedent for an auxiliary always-on-top window: transparent, frameless, `focusable: false`, click-through by default, hover hit-testing polled in main because `setIgnoreMouseEvents(..., { forward: true })` is unsupported on Linux. A floating voice HUD that survives app-switching reuses this shape. |
+| `src/main/plugins/consent-window.ts` | The precedent for a dedicated auxiliary window with its OWN minimal preload and page, so a surface cannot reach the full IPC bridge. |
+| `src/main/web-server/routes/wsRoute.ts` | The authenticated WebSocket at `/$TOKEN/ws`. Clients connect with an optional `?sessionId=`, receive a `connected` frame, then an initial state sync. The phone's WebRTC signaling rides this socket, so it inherits the existing token auth instead of opening a second listener. |
+| `src/main/web-server/services/broadcastService.ts` | `broadcastToAll` / `broadcastToSession` over the connected client map. The fan-out shape the voice event stream copies for non-IPC clients. |
+| `src/shared/plugins/first-party.ts` | The Encore feature registry, keyed by Encore flag, carrying an honest broker-permission disclosure and any supervised background services. |
+| `src/renderer/components/Settings/Extensions/extensionModel.ts` | Projects first-party definitions into marketplace tiles; `beta` is a presentation concern that lives here, not in the shared registry. |
+
+## Where tab state actually lives
+
+This is the constraint that shapes the dispatch executor, and it is easy to get wrong.
+
+**Main has no tab authority.** Tab state lives in the renderer. Even a web or CLI request to open
+a tab is forwarded to the renderer for execution: `src/main/web-server/callbacks/tabCallbacks.ts`
+sends `remote:newTab`, `remote:selectTab`, `remote:renameTab`, `remote:closeTab` and, for the
+create case, waits on a one-shot `remote:newTab:response:` channel with a 5 second timeout.
+`src/main/ipc/handlers/tabs.ts` exists only so the renderer can report a close back to main.
+
+So `executeRouteDecision()` does not create tabs. It resolves a decision into the same
+`remote:*` messages the web server already uses, and reads its roster from the persisted
+sessions store (`sessionsStore.get('sessions', [])`, the same source
+`registerSessionCallbacks` uses). Hand-rolling a parallel tab creation path in main would produce
+tabs the renderer does not know about.
+
+The corollary for the phone: the iPhone never talks to tabs either. It sends a voice event, main
+routes it, and the renderer performs it. One execution path, three possible originators.
+
+### What the executor actually does
+
+`src/main/acappella/dispatch/route-executor.ts` maps each `tabAction` onto one existing channel:
+
+| Decision | Channel(s) | Reported action |
+| -------------------- | ------------------------------------------------------------------ | --------------- |
+| `new` with a prompt | `remote:newAITabWithPrompt`, then `remote:renameTab` for `tabName` | `created` |
+| `new` with no prompt | `remote:newTab`, then `remote:renameTab` | `created` |
+| `current` | `remote:selectSession`, then `remote:executeCommand` | `focused` |
+| `recall` | `remote:selectSession`, then `remote:executeCommand` | `recalled` |
+
+Creation and prompt delivery are one atomic renderer operation because a separate create-then-send
+leaves an orphan tab behind whenever the send is dropped. The `dispatch` event is emitted only
+after the renderer answers, so "opened a new tab named Auth Refactor on agent Backend" is a report,
+not a hope.
+
+Four rules the executor holds to, all of them about refusing to guess:
+
+- **The roster is re-read at dispatch time**, not carried over from routing. The user can close a
+ tab while a decision is in flight, and the fresh read is the authority.
+- **A recalled tab that is gone is a `dispatch-failed`**, never a quiet landing in some other tab.
+ Recall is a promise to return somewhere specific.
+- **A `conductor` target resolves to the session's bound agent, then the agent the desktop is
+ showing, then the only agent there is.** With several agents and no signal, it fails: a spoken
+ instruction in the wrong repository is worse than an error.
+- **A rejected delivery receipt is a failure, not a `promptSent: false` footnote.** The session
+ holds the floor open waiting for a reply, so a dropped prompt has to be reported as one.
+
+The renderer round trip sits behind a `VoiceRendererBridge` interface, for the same reason the
+session service takes its providers injected: the routing rules are testable without an Electron
+window, and the phone leg gets the same executor with a different bridge.
+
+## Provider tiers
+
+A Cappella supports two fundamentally different pipeline shapes behind one set of interfaces.
+
+### Cascade tier (STT then Brain then TTS)
+
+Three independent providers, swappable individually:
+
+- `SttProvider`: streaming `feed(pcm)` with `partial` and `final` callbacks.
+- `BrainProvider`: `route(input, context)` returns a `RouteDecision`; `converse(agentText, context)`
+ reshapes an agent's terminal-shaped answer into spoken-form text.
+- `TtsProvider`: `speak(text)` returns an async iterable of audio chunks, plus `cancel()`.
+
+The cascade tier is the default because each stage is independently substitutable: local Whisper
+with cloud TTS, or a cloud brain with local everything else. Latency is the sum of the stages,
+which is why barge-in matters so much (see below).
+
+### Realtime tier (speech to speech)
+
+A single provider owns microphone audio in and speaker audio out, with routing expressed as tool
+calls. Latency is far lower and prosody is far better, but the stages are no longer separable and
+the audio must leave the machine.
+
+The realtime tier implements the same three interfaces as a **fused adapter**: `SttProvider`
+emits transcripts the realtime session already produces, `BrainProvider.route()` maps to the
+session's tool-call channel, and `TtsProvider` becomes a passthrough. The session service cannot
+tell the tiers apart, which is the point.
+
+### Provider resolution rules
+
+`src/main/acappella/providers/provider-registry.ts` resolves the active trio from settings, and is
+the only module allowed to import a concrete provider. Two rules are non-negotiable:
+
+1. When nothing is configured, resolve the **mock** trio. The pipeline must always be runnable.
+ The one per-build exception is STT in a development build, which defaults to `echo-stt`: it
+ consumes real PCM and reports the speech segments it heard, so `npm run dev` exercises the whole
+ audio path without a settings edit. A default is not a substitution and is not reported as one.
+ Whether a microphone is opened at all follows from `SttProvider.acceptsAudio` rather than from a
+ list of provider ids, so a text-in provider never costs the user a permission prompt.
+2. **Never substitute anything for a provider that cannot be built.** Not a cloud provider, which
+ would spend the user's money and send their microphone somewhere they did not choose; and not
+ the mock either, because a session that transcribes nothing while looking healthy hides the
+ reason. A slot whose provider is unknown or unrunnable resolves to an `Unresolved*` provider
+ that refuses BY NAME the first time anything asks it to work, and the resolution carries a
+ `VoiceProviderSubstitution` recording what was asked for and why it could not run. That record
+ is logged and handed to the caller to put in front of the user.
+
+There is no search over the catalog that could land on a different tier, which is what makes rule 2
+structural rather than a promise. The mock tier is selected, never substituted in: it is what an
+unconfigured install runs on purpose, and it is what the tests and the dev harness drive.
+
+Phase 05 added the concrete backends behind these rules: `providers/local/` (Whisper, Kokoro,
+Qwen3 through `runtime/native-loader.ts`), `providers/hosted/` (OpenAI STT and Brain, Anthropic
+Brain, ElevenLabs TTS, all through one retrying and classifying transport), and
+`providers/realtime/` for the speech-to-speech tier. Which engine fills which slot, and what each
+one needs and sends, is declared once in `src/shared/acappella/provider-catalog.ts`, so the
+capability gate, the registry, the credential layer, and the settings panel cannot drift apart.
+
+API keys live in the OS keychain (`providers/credentials.ts`) and never in `settings.json`. Every
+turn is timed per hop in `telemetry/turn-metrics.ts`; see [[latency-baseline]].
+
+## Client model
+
+Both clients are peers on [[voice-session-protocol]]. Neither owns session state.
+
+| | Desktop renderer | iPhone |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
+| Transport | Electron IPC (`acappella:*` plus a push `acappella:event` channel) | Authenticated WebSocket for signaling, WebRTC for media |
+| Audio capture | Local device, or none in dev-harness mode | Device microphone over WebRTC |
+| Responsibilities | Render HUD state, transcript, dispatch narration; send `submitUtterance` / `interrupt` / `stopWord`; execute `remote:*` tab operations | Capture and play audio, render the project wheel from `agent-roster`, send the same three commands |
+| State ownership | None. Mirrors the event stream into `voiceSessionStore`. | None. |
+
+Because clients only mirror, a client can join mid-session and catch up: it reads `get-state` and
+`get-roster`, then follows `seq` for gaps. A gap means the client missed events and should
+re-read state rather than guess.
+
+Transport choice for the phone leg is argued in [[adr-001-webrtc-transport]].
+
+The desktop transport is `src/main/ipc/handlers/acappella.ts` plus the `window.maestro.voice.*`
+preload namespace. Its channel table and the four properties that binding has to hold (lazy
+construction, broadcast fan-out, the `ACappellaDisabled` gate, and a null snapshot before the
+first start) are in [[voice-session-protocol]].
+
+## Session lifecycle and the state machine
+
+States: `idle | arming | listening | transcribing | routing | dispatching | speaking | interrupted | error`.
+
+The legal transitions live in a `VOICE_STATE_TRANSITIONS` table in
+`src/shared/acappella/session-state.ts`. An illegal transition throws rather than smearing state,
+because a voice UI that is silently in two states at once is unfixable in the field.
+
+```mermaid
+stateDiagram-v2
+ [*] --> idle
+ idle --> arming: startSession
+ arming --> listening: listen-start
+ listening --> transcribing: final-transcript
+ transcribing --> routing: brain.route
+ routing --> dispatching: route-decision
+ dispatching --> speaking: agent-reply
+ speaking --> interrupted: barge-in
+ interrupted --> listening: floor retained
+ speaking --> listening: speak-end
+ listening --> idle: stop-word / stopSession
+ arming --> error: provider unavailable
+ routing --> error: no agent matched
+ error --> idle: stopSession
+```
+
+### Barge-in and stop are different
+
+This distinction is load-bearing and every phase inherits it:
+
+- **Barge-in** cancels TTS and **keeps the floor**. The session goes `speaking -> interrupted ->
+listening`. You talked over the assistant; it shuts up and keeps listening.
+- **Stop word** ends the session and returns to `idle`. The floor is released.
+
+Conflating them produces the single most annoying failure mode in voice interfaces: interrupting
+the assistant hangs up on it.
+
+## Error policy
+
+Per the repo's Sentry policy, unexpected exceptions bubble. Only known failure modes are
+classified into `session-error` events:
+
+- `provider-unavailable`: a configured provider cannot start (model missing, no API key, device
+ busy).
+- `no-agent-matched`: the brain could not resolve a target and the conductor fallback is disabled.
+- `dispatch-failed`: the renderer did not answer a `remote:newTab` within its timeout.
+
+Anything else is a bug and should reach Sentry with context.
+
+Two places have no caller to bubble to, so they report explicitly instead of rethrowing: the turn
+pipeline (which runs from an STT callback) and the speech run (whose only caller is the reply
+seam, and from there an IPC handler). Both call `captureException` with the session context, emit
+`listen-stop(error)`, and park the session in `error`. Swallowing is not the point: an escaping
+rejection there would reach the process handler stripped of session context AND leave the session
+holding a floor nothing will ever hand back, which reads to the user as a frozen HUD.
+
+## What Phase 01 deliberately does not do
+
+- No network calls, no API keys, no model downloads. The mock tier proves the pipeline end to end.
+- Enabling the Encore flag opens no device and starts no service. It only makes the Voice Setup
+ surface reachable.
+- No WebRTC yet. The transport decision is recorded now so the protocol is designed for it, but
+ the phone leg is a later phase.
+
+## Related
+
+- [[voice-session-protocol]] - every event, payload, and direction.
+- [[model-manager]] - the local model catalog, downloads, verification, and the capability gate.
+- [[packaging-notes]] - native runtimes, asar unpacking, entitlements, notarization, and the
+ microphone permission.
+- [[latency-baseline]] - the per-hop latency budget for each provider configuration, and how it is
+ measured.
+- [[adr-001-webrtc-transport]] - why WebRTC beats WebSocket plus Opus for the phone leg.
+- [[adr-002-main-process-session]] - why the session is headless in main.
diff --git a/docs/architecture/acappella/transport-and-pairing.md b/docs/architecture/acappella/transport-and-pairing.md
new file mode 100644
index 0000000000..532c351f62
--- /dev/null
+++ b/docs/architecture/acappella/transport-and-pairing.md
@@ -0,0 +1,230 @@
+---
+type: architecture
+title: A Cappella Transport and Pairing
+created: 2026-08-15
+tags:
+ - voice
+ - architecture
+ - acappella
+ - webrtc
+ - pairing
+related:
+ - '[[system-overview]]'
+ - '[[adr-001-webrtc-transport]]'
+ - '[[voice-session-protocol]]'
+---
+
+# A Cappella Transport and Pairing
+
+How a phone becomes a microphone and a speaker for a desktop that is somewhere else, and what
+that costs on each kind of network.
+
+The design in one sentence: **the phone carries audio and nothing else.** One STT, one TTS, one
+router, all still in the main process, so whatever provider you picked on the desktop is exactly
+what you hear on the walk. A remote utterance is not a second pipeline; it is the same pipeline
+with a different microphone attached.
+
+## The parts
+
+| Piece | Where it lives | What it owns |
+| ------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------- |
+| Pairing service | `src/main/acappella/pairing/pairing-service.ts` | Codes, desktop approval, hashed device tokens, revocation |
+| Discovery | `src/main/acappella/pairing/discovery.ts` | The Bonjour `_maestro._tcp` advert, and its off switch |
+| Signaling | `src/main/acappella/transport/signaling.ts` | Offer/answer/ICE over the existing authenticated WebSocket |
+| ICE configuration | `src/main/acappella/transport/ice-config.ts` | STUN, TURN, candidate classification, the reach statement |
+| Remote session semantics | `src/main/acappella/transport/remote-session.ts` | One floor, takeover, clean teardown on connection loss |
+| Peer connection | `src/renderer/acappella-audio/peer-connection.ts` | `RTCPeerConnection`, Opus tuning, data channels, `getStats()` |
+| Data-channel protocol | `src/shared/acappella/device-protocol.ts` | Version handshake, message shapes, reliable/unreliable routing |
+| Device management UI | `src/renderer/components/Settings/ACappella/PairedDevicesPanel.tsx` | QR code, approval, device list, revoke, TURN, Test Connection |
+
+The peer terminates in the hidden audio window from Phase 02, because that window already owns
+the `AudioContext`: a remote track has to meet the local microphone somewhere, and this is the
+only place both exist. Electron ships Chromium's libwebrtc, so the desktop needs no new native
+dependency for any of it.
+
+## The connection matrix
+
+Three paths, tried in this order, and honestly labelled everywhere they are shown:
+
+| Path | Candidate type | Infrastructure needed | Typical latency added | Works on |
+| ----------------------- | -------------- | ------------------------------------ | ---------------------- | ---------------------------------------- |
+| Host (LAN) | `lan` | None | Under 5 ms | Same WiFi, same wire |
+| Host (overlay) | `lan` | Tailscale/ZeroTier | Overlay's own latency | Anywhere the overlay reaches |
+| Server reflexive (STUN) | `stun` | A STUN server | None (media is direct) | Most home NATs |
+| Relayed (TURN) | `relay` | **A TURN server you run or pay for** | One extra hop | Cellular, hotel WiFi, corporate networks |
+
+**The overlay row is the interesting one.** A Tailscale-style network hands both machines a
+routable address for each other, so the connection is a plain host candidate and it connects
+instantly, from anywhere, with no STUN, no TURN, and no port forwarding. If you already run one,
+this is the whole answer and everything below it is a fallback. The pairing QR code carries every
+local address the desktop has, overlay addresses included, so a phone that is on the overlay but
+not on the WiFi still connects directly.
+
+### TURN is not optional for cellular
+
+A phone on a mobile network sits behind carrier-grade NAT. CGNAT shares one public address across
+thousands of subscribers and does not support the endpoint-independent mapping that hole punching
+needs. No amount of STUN gets through it. If you want voice to work while walking down the street
+on LTE, **you need a TURN server**, somebody has to run it, and somebody has to pay for the
+bandwidth every second of audio goes through it.
+
+Settings states this as a fact rather than hiding it behind a warning triangle, and the Test
+Connection button proves it either way: a `relay` candidate can only be gathered by successfully
+authenticating to a TURN server, so its presence is evidence rather than configuration.
+
+### The Cloudflare quick tunnel cannot carry this
+
+`src/main/tunnel-manager.ts` runs a Cloudflare quick tunnel so the browser interface is reachable
+from outside your network. **It cannot carry the voice audio.** It is an HTTPS reverse proxy and
+terminates TCP at Cloudflare; the media leg is a direct UDP association between two peers, chosen
+by ICE. The two are separate paths that happen to be used by the same feature:
+
+- **Signaling** (offer, answer, ICE candidates) is WebSocket traffic on `/$TOKEN/ws`, so it goes
+ through the tunnel perfectly well.
+- **Media** never touches the tunnel. It is LAN, STUN-punched, or relayed through TURN.
+
+So "the tunnel is up" tells you nothing about whether audio will flow, and a tunnel URL is not a
+substitute for a TURN server. This is written into the settings copy (`TUNNEL_MEDIA_NOTE`) because
+a user who does not know it will blame the wrong component every time.
+
+## Pairing
+
+```
+Device Desktop
+ | |
+ | scans QR / enters code | startPairing() -> 6-char code, 2 min TTL
+ |------ pair-claim ------------->| claim() consumes the code, creates a request
+ |<----- pair-pending ------------|
+ | | *** a human clicks Approve, looking at the
+ | | name and platform of the thing asking ***
+ |------ pair-poll -------------->| approve() mints a 32-byte token,
+ |<----- pair-approved -----------| persists ONLY its salted SHA-256
+ | |
+ |------ auth (deviceId, token) ->| authenticate() -> signaling session
+ |<----- authenticated -----------| (ICE servers, negotiated protocol version)
+ |------ offer ------------------>| rate limited, 6 per minute, sliding window
+ |<----- answer ------------------|
+ |<===== ICE candidates =========>|
+ |<~~~~~ audio + data channels ~~>|
+```
+
+Four properties this flow is arranged around:
+
+1. **Knowing the code is not enough.** A code is short enough to read over a shoulder, so it buys
+ a row in a dialog and nothing else. Pairing completes only on an affirmative action on the
+ desktop.
+2. **The window is short and one-shot.** Two minutes, and the code is spent by the FIRST claim
+ whether or not the human approves - a denied request must not leave a live code behind for
+ whoever was watching.
+3. **The token is never stored in plain text.** `userData/acappella/devices.json` holds a salted
+ SHA-256. A stolen file discloses device names, which is the smallest disclosure that still lets
+ a returning device authenticate with no server involved.
+4. **Revocation is immediate.** `revoke()` fires an event before the disk write completes; the
+ signaling service turns that into a torn-down peer connection and a closed voice session. A
+ revocation that only applied at the next connect would be useless in the one situation anybody
+ ever uses it.
+
+The QR payload carries the host candidates, the port, the server token, the pairing code, and a
+fingerprint derived from the server token. The fingerprint is shown on both screens so a user can
+compare four characters and notice a man in the middle.
+
+### Discovery
+
+The desktop advertises `_maestro._tcp` over Bonjour so a device on the same network finds it
+without anybody typing an address. Three things about it:
+
+- It is a convenience, never the connection. The QR code carries the addresses directly, and
+ manual host entry always works.
+- It is off-switchable, and the switch is real: broadcasting a machine name and a port to every
+ device on a network is a disclosure some people do not want to make.
+- It carries no secret. The TXT record holds the app version, the protocol version, the machine
+ name, and the pairing fingerprint. Never the token and never a pairing code - an advert is
+ readable by everything on the network.
+
+The mDNS responder is loaded optionally (`bonjour-service` if present). When it is absent the
+advert reports `unavailable` with the sentence that says so, rather than failing silently.
+
+## The data channels
+
+Two, because the two kinds of message have opposite failure preferences:
+
+| Channel | Config | Carries |
+| ----------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
+| `acappella-state` | ordered, reliable | Agent roster, tab state, route decisions, dispatches, errors, floor state, revocation |
+| `acappella-live` | unordered, `maxRetransmits: 0` | Audio level, partial transcripts, push-to-talk press/release, barge-in, link quality |
+
+Session events travel as `{ type: 'voice-event', event }` - a Phase 01 `VoiceEvent`, unchanged.
+There is deliberately no parallel vocabulary: the phone reads the same object graph the desktop
+renderer and the CLI read. Only the things a peer connection genuinely adds (the version
+handshake, floor control, link quality) are new message types.
+
+Push-to-talk rides the lossy channel with the rest of the gesture traffic. A dropped RELEASE
+cannot leave a hot microphone: the floor's idle timeout closes it, the next press is idempotent,
+and the desktop re-sends the floor state it observes.
+
+Version negotiation happens at `auth`, before the credential is even checked, and a mismatch is
+refused with a sentence naming which end has to update. An old client that half works is worse
+than one that will not connect.
+
+## Remote session semantics
+
+- **One floor, last press wins.** A device that presses talk takes the floor from whoever had it,
+ and the displaced device is told immediately so its button snaps back. Every device here was
+ individually approved by the person doing the pressing; the alternative rule ends with a user
+ pressing talk on the phone in their hand and nothing happening because a laptop in another room
+ holds the floor.
+- **A stale release cannot close a live floor.** Only the current holder's release does anything.
+- **The path is identical.** A remote press drives the same `FloorController` the desktop hotkey
+ drives, and opens an ordinary session whose only difference is `VoiceOrigin`. The origin exists
+ so the desktop HUD can name the device that is listening, not so anything can branch on it.
+- **A dropped connection ends the session cleanly.** Speech is cancelled first (the chunks are
+ already queued in the audio host), then the session closes. An ICE `disconnected` is NOT that
+ trigger: it is what a WiFi-to-LTE handover looks like, and hanging up there would hang up on
+ every user this transport exists for.
+- **Wake word and stop word stay local to whichever device is capturing.** No audio leaves a
+ device before its wake phrase fires. This falls out of the design rather than being enforced:
+ the phone's microphone is not sent anywhere until the phone opens the floor.
+
+## Audio configuration
+
+Opus, mono, with in-band FEC and DTX on and a 24 kbps target:
+
+- **FEC** is what makes 5% packet loss sound like nothing instead of like a robot.
+- **DTX** stops a phone in a pocket transmitting silence over a metered radio.
+- **Mono at 24 kbps** is transparent for speech, and the pipeline downmixes to one channel anyway.
+
+Both the SDP `fmtp` parameters and `RTCRtpSender.setParameters` are set, because either one alone
+is routinely ignored depending on which end negotiated what.
+
+**Echo cancellation for the remote path runs on the device, not on the desktop.** The echo happens
+in the room the phone is in, and cancelling it requires the phone's own speaker output as the
+reference signal - which only the phone has. The desktop asks for it
+(`RemoteAudioConfig.requestRemoteEchoCancellation`) and applies its own AEC to its own microphone,
+where it works. Any claim that the desktop cancels the phone's echo would be false.
+
+## Acceptance: what to check by hand
+
+The suites in `src/__tests__/main/acappella/` and
+`src/__tests__/renderer/acappella-audio/peer-connection.test.ts` run in jsdom with a mocked
+`RTCPeerConnection` and no network. They cannot prove audio flows. These steps can:
+
+- [ ] Pair a second machine over LAN with the QR code, approve it on the desktop, hold its
+ push-to-talk, and speak. The desktop routes and dispatches identically to a local utterance
+ and the reply is audible on the remote device in the configured voice.
+- [ ] Record the candidate type the device list shows. On the same network it must say
+ `Direct (LAN or overlay)`.
+- [ ] Repeat over a Tailscale-style overlay with the desktop off the device's WiFi. Still
+ `Direct (LAN or overlay)`, because an overlay address is a host candidate.
+- [ ] With a TURN server configured, repeat over cellular. The device list must say
+ `Relayed (TURN)`, and Test Connection must report a relay candidate.
+- [ ] Walk from WiFi to cellular mid-conversation. The peer renegotiates; the session survives.
+- [ ] Revoke the device mid-conversation. The connection drops immediately, the session ends, and
+ no speaking state or open floor is left behind.
+- [ ] Two devices: press talk on the second while the first holds the floor. The first shows the
+ takeover and its microphone closes; the second is heard.
+
+## Related
+
+- [[system-overview]] - where the phone sits in the client model.
+- [[adr-001-webrtc-transport]] - why WebRTC rather than Opus frames over the WebSocket.
+- [[voice-session-protocol]] - the event vocabulary the data channel carries unchanged.
diff --git a/docs/architecture/acappella/voice-session-protocol.md b/docs/architecture/acappella/voice-session-protocol.md
new file mode 100644
index 0000000000..e598f5cdf7
--- /dev/null
+++ b/docs/architecture/acappella/voice-session-protocol.md
@@ -0,0 +1,446 @@
+---
+type: architecture
+title: A Cappella Voice Session Protocol
+created: 2026-08-14
+tags:
+ - voice
+ - architecture
+ - acappella
+ - protocol
+related:
+ - '[[system-overview]]'
+ - '[[adr-001-webrtc-transport]]'
+ - '[[adr-002-main-process-session]]'
+---
+
+# Voice Session Protocol
+
+The protocol is the contract between the headless session service in the main process and every
+client (desktop renderer today, iPhone later, CLI if it ever wants in). It is defined in
+`src/shared/acappella/protocol.ts` as a discriminated union on `type`.
+
+The protocol is deliberately transport-agnostic. The same object graph travels over Electron IPC
+(`acappella:event`) and over the authenticated WebSocket at `/$TOKEN/ws`. Nothing in it may refer
+to a `BrowserWindow`, a DOM node, or a React store. See [[adr-002-main-process-session]].
+
+## Envelope
+
+Every event carries the same three fields:
+
+```ts
+interface VoiceEventBase {
+ /** The voice session this event belongs to. Not an agent id. */
+ sessionId: string;
+ /** Monotonic per voice session, starting at 1. A gap means events were lost. */
+ seq: number;
+ /** Emission time, epoch ms. */
+ ts: number;
+}
+```
+
+`sessionId` is the **voice** session id, minted by `startSession()`. It is not an agent id and it
+is not a provider session id. When a voice session is bound to an agent, the agent id travels in
+the scope, never in `sessionId`.
+
+`seq` exists so a client can detect gaps. It is monotonic and per voice session, incremented by
+the service for every emitted event regardless of subscriber count. A client that sees `seq` jump
+must not interpolate: it re-reads `acappella:get-state` and `acappella:get-roster` and resumes.
+
+## Direction
+
+`client -> service` events are commands. `service -> client` events are announcements, fanned out
+to every subscriber so two clients watching the same session see identical state.
+
+Four events travel **both** ways (`wake`, `final-transcript`, `barge-in`, `stop-word`). When a
+client sends one, the service validates it against the
+state machine and, if it is legal, echoes it outward with a fresh `seq` so every other client sees
+it. The echoed copy is the authoritative one; a client must render its own optimistic state only
+until the echo lands.
+
+## Event catalogue
+
+| Event | Direction | Emitted when |
+| -------------------- | ----------------- | ---------------------------------------------------------------------------------- |
+| `wake` | both | A wake word or push-to-talk key fires on a client, then echoed as the session arms |
+| `listen-start` | service -> client | The floor opens and audio is being consumed |
+| `listen-stop` | service -> client | The floor closes (endpointed, stopped, or interrupted) |
+| `partial-transcript` | service -> client | STT produces an interim hypothesis |
+| `final-transcript` | both | STT settles an utterance; inbound when the client owns STT |
+| `route-decision` | service -> client | The brain resolves a target, tab action, and prompt |
+| `dispatch` | service -> client | The decision was executed against a real agent and tab |
+| `agent-reply` | service -> client | The agent produced text worth speaking |
+| `speak-start` | service -> client | TTS begins a reply |
+| `speak-sentence` | service -> client | One sentence of the reply is spoken |
+| `speak-end` | service -> client | TTS finishes or is cancelled |
+| `barge-in` | both | The user speaks or clicks over active speech |
+| `stop-word` | both | The stop word or Stop button ends the session |
+| `session-error` | service -> client | A classified, known failure mode |
+| `audio-level` | service -> client | A downsampled input level, ~20 a second, so a client can draw a meter |
+| `mic-state` | service -> client | The microphone's permission, device, or availability changes |
+| `tab-state` | service -> client | The bound agent's tab set or active tab changes |
+| `agent-roster` | service -> client | Roster snapshot, on subscribe and on change |
+
+## Payloads
+
+### `wake` (both)
+
+```ts
+{
+ type: 'wake';
+ source: 'wake-word' | 'hotkey' | 'client-button';
+ scope: VoiceScope;
+}
+```
+
+`VoiceScope` is `{ kind: 'conductor' }` or `{ kind: 'agent'; sessionId: string }`, where that
+`sessionId` is an **agent** id. Inbound, `wake` requests a session in that scope. Outbound, it
+announces `idle -> arming`.
+
+### `listen-start` (service -> client)
+
+```ts
+{
+ type: 'listen-start';
+ scope: VoiceScope;
+ sttProviderId: string;
+}
+```
+
+State becomes `listening`. `sttProviderId` lets the HUD show which tier is active (`mock`,
+`whisper-local`, `openai-realtime`, ...), which matters because provider substitution is never
+silent.
+
+### `listen-stop` (service -> client)
+
+```ts
+{
+ type: 'listen-stop';
+ reason: 'endpoint' | 'stopped' | 'interrupted' | 'error';
+}
+```
+
+### `partial-transcript` (service -> client)
+
+```ts
+{
+ type: 'partial-transcript';
+ text: string;
+ stability: number;
+}
+```
+
+`text` is the full hypothesis so far, not a delta, so a client that missed one partial still
+renders correctly. `stability` is 0 to 1; the mock STT emits two partials with rising stability
+before the final.
+
+### `final-transcript` (both)
+
+```ts
+{ type: 'final-transcript'; text: string; confidence: number; durationMs?: number }
+```
+
+Outbound in the cascade tier. **Inbound** when a client owns transcription, which is exactly the
+iPhone case if on-device dictation ever beats the desktop pipeline. Either way it lands on the
+same seam as `submitUtterance(text)`, so the dev harness and a real microphone are
+indistinguishable to everything downstream.
+
+### `route-decision` (service -> client)
+
+```ts
+{
+ type: 'route-decision';
+ decision: RouteDecision;
+ brainProviderId: string;
+ latencyMs: number;
+}
+```
+
+`RouteDecision` is defined in `src/shared/acappella/route-decision.ts`:
+
+```ts
+{
+ target: 'conductor' | { sessionId: string };
+ tabAction: 'current' | 'new' | 'recall';
+ tabId?: string;
+ tabName?: string;
+ prompt: string;
+ confidence: number;
+}
+```
+
+The same file exports a JSON Schema constant for this shape. Phase 07 compiles that schema into a
+GBNF grammar so a local model is structurally incapable of emitting an invalid decision. Keeping
+the schema next to the type is what makes that possible without a second, drifting definition.
+
+### `dispatch` (service -> client)
+
+```ts
+{
+ type: 'dispatch';
+ agentSessionId: string;
+ agentName: string;
+ tabId: string;
+ tabName?: string;
+ action: 'focused' | 'created' | 'recalled';
+ promptSent: boolean;
+}
+```
+
+This is what lets any client narrate "opened a new tab named Auth Refactor on agent Backend". It
+is emitted **after** the renderer confirms the operation, not when it is requested, because tab
+creation is a round trip that can time out (see [[system-overview]], "Where tab state actually
+lives").
+
+### `agent-reply` (service -> client)
+
+```ts
+{
+ type: 'agent-reply';
+ agentSessionId: string;
+ tabId: string;
+ text: string;
+ spokenText: string;
+}
+```
+
+`text` is what the agent actually wrote. `spokenText` is `BrainProvider.converse()` output: the
+same content reshaped for the ear, because reading a diff aloud is useless. Clients that show a
+transcript should show `text` and speak `spokenText`.
+
+### `speak-start` / `speak-sentence` / `speak-end` (service -> client)
+
+```ts
+{
+ type: 'speak-start';
+ utteranceId: string;
+ sentenceCount: number;
+ ttsProviderId: string;
+}
+{
+ type: 'speak-sentence';
+ utteranceId: string;
+ index: number;
+ text: string;
+}
+{
+ type: 'speak-end';
+ utteranceId: string;
+ reason: 'complete' | 'cancelled' | 'error';
+}
+```
+
+`utteranceId` scopes a speech run so a late `speak-sentence` from a cancelled run can be dropped
+rather than rendered after the next reply started. Sentence granularity is what makes barge-in
+feel instant: the client already knows the sentence boundary it was cut at.
+
+### `barge-in` (both)
+
+```ts
+{ type: 'barge-in'; source: 'voice' | 'client-button'; cancelledUtteranceId?: string }
+```
+
+Cancels TTS and **keeps the floor**: `speaking -> interrupted -> listening`. It does not end the
+session. This is the difference that makes the interface usable.
+
+### `stop-word` (both)
+
+```ts
+{ type: 'stop-word'; phrase?: string; source: 'voice' | 'client-button' }
+```
+
+Ends the session: any state `-> idle`. TTS is cancelled, the floor is released, providers are torn
+down.
+
+### `session-error` (service -> client)
+
+```ts
+{
+ type: 'session-error';
+ code: 'provider-unavailable' | 'no-agent-matched' | 'dispatch-failed' | 'audio-capture-failed';
+ message: string;
+ recoverable: boolean;
+ providerId?: string;
+}
+```
+
+The union is closed on purpose. Only classified, known failure modes become events; anything else
+bubbles to Sentry per the repo error policy. A new code means a new, deliberately handled failure
+mode, not a catch-all.
+
+### `audio-level` (service -> client)
+
+```ts
+{
+ type: 'audio-level';
+ level: number;
+ speech: boolean;
+}
+```
+
+The input level, downsampled to roughly 20 a second by `main/acappella/audio/level-meter.ts`, so a
+client can draw a live meter without ever receiving PCM. `level` is a linear RMS from 0 to 1 and the
+client picks its own curve; `speech` says whether the detector held the floor open across the
+window, which is how a meter shows the difference between a loud room and a person talking.
+
+Silence is published once and then withheld until something moves. An open microphone in a quiet
+room is the normal state of a session, and 20 identical zeros a second is traffic nobody can use.
+
+### `mic-state` (service -> client)
+
+```ts
+{
+ type: 'mic-state';
+ permission: 'unknown' | 'granted' | 'denied';
+ capturing: boolean;
+ deviceId: string | null;
+ deviceLabel: string | null;
+ issue: 'permission-denied' | 'no-device' | 'device-lost' | 'unavailable' | null;
+ deviceChanged: boolean;
+}
+```
+
+Every transition is published, including the benign ones. The failure this exists to prevent is a
+client showing a listening indicator over a microphone that will never produce a transcript: a
+denied permission and a quiet room are indistinguishable from the rest of the stream. `deviceLabel`
+is null until permission is granted, because Chromium redacts device labels before that.
+
+### `tab-state` (service -> client)
+
+```ts
+{ type: 'tab-state'; agentSessionId: string; tabs: RosterTab[]; activeTabId: string | null }
+```
+
+### `agent-roster` (service -> client)
+
+```ts
+{ type: 'agent-roster'; agents: RosterAgent[] }
+
+interface RosterAgent {
+ sessionId: string;
+ name: string;
+ agentType: string;
+ cwd: string;
+ tabs: RosterTab[];
+}
+
+interface RosterTab {
+ id: string;
+ name: string | null;
+ lastActiveAt: number | null;
+}
+```
+
+The roster is the brain's routing context and, later, the phone's project wheel. It is built by
+`buildAgentRoster()` in `src/main/acappella/dispatch/route-executor.ts` from the main-process
+stores via `src/main/stores/getters.ts`, the same source the web server's session callbacks read.
+It is deliberately compact: no logs, no usage stats, nothing that would make it expensive to push
+on every change or to send to a model as context.
+
+## Flow
+
+A complete turn, with the mock tier:
+
+```mermaid
+sequenceDiagram
+ participant C as Client (HUD)
+ participant S as VoiceSessionService
+ participant B as BrainProvider
+ participant R as Renderer (tabs)
+ participant T as TtsProvider
+ C->>S: wake / startSession
+ S-->>C: wake, listen-start (seq 1,2)
+ C->>S: submitUtterance("new tab on backend about auth")
+ S-->>C: partial-transcript x2, final-transcript (seq 3-5)
+ S->>B: route(text, roster)
+ S-->>C: route-decision (seq 6)
+ S->>R: remote:newTab + remote:renameTab
+ R-->>S: response channel
+ S-->>C: dispatch (seq 7)
+ S-->>C: agent-reply (seq 8)
+ S->>T: speak(spokenText)
+ S-->>C: speak-start, speak-sentence..., speak-end (seq 9+)
+ C->>S: barge-in
+ S-->>C: speak-end(cancelled), listen-start
+```
+
+## Electron IPC binding
+
+The desktop client speaks the protocol over `src/main/ipc/handlers/acappella.ts`, exposed to the
+renderer as `window.maestro.voice.*` (`src/main/preload/acappella.ts`). The handlers are
+registered from `setupIpcHandlers()` in `src/main/ipc/bootstrap/index.ts`, which is the path the
+running app actually takes; `registerAllHandlers()` in `handlers/index.ts` is not called at
+runtime, so a handler wired only there would be dead.
+
+| Channel | Preload method | Returns |
+| ------------------------------ | -------------------------- | ------------------------------------------------------ |
+| `acappella:start-session` | `voice.start(scope?)` | `{ snapshot, substitutions }` |
+| `acappella:stop-session` | `voice.stop()` | `void` |
+| `acappella:submit-utterance` | `voice.submitUtterance()` | `boolean` (false when the state cannot take one) |
+| `acappella:interrupt` | `voice.interrupt(source)` | `boolean` (false when nothing is speaking) |
+| `acappella:stop-word` | `voice.stopWord(payload?)` | `void` |
+| `acappella:submit-agent-reply` | `voice.submitAgentReply()` | `boolean` (false when no reply was awaited) |
+| `acappella:get-roster` | `voice.getRoster()` | `RosterAgent[]` |
+| `acappella:get-state` | `voice.getState()` | `VoiceSessionSnapshot`, or null before the first start |
+| `acappella:event` (push) | `voice.onEvent(handler)` | every `VoiceEvent`, in `seq` order |
+
+Five properties of this binding are deliberate:
+
+- **Registration builds nothing.** The service, its provider trio, and the dispatch executor are
+ all constructed on the first `start-session`. Enabling the Encore Feature opens no device and
+ downloads nothing.
+- **Events are broadcast, not addressed.** `acappella:event` goes to every window and to the
+ web-desktop bridge through `safeSend`, matching the multi-window invariant in
+ `src/main/utils/safe-send.ts`. There is no per-window subscriber list: a client that does not
+ want the stream simply does not listen.
+- **The Encore gate rejects with `ACappellaDisabled`**, so the renderer can tell "feature off"
+ from "no session". `stop-session` is the one ungated channel, because toggling the feature off
+ mid-session must still be able to release the floor.
+- **`get-state` returns null before the first start.** Synthesising an idle snapshot would have to
+ name provider ids that nothing has resolved yet, and reporting a requested-but-unavailable
+ provider as the running one is the substitution lie in a different costume.
+- **The floor is released on `will-quit`.** Registering the handlers also registers an
+ `app.on('will-quit')` that calls `disposeVoiceSessionService()`. The mock tier holds no OS
+ device, but a real microphone (Phase 05) does, and a live session must not outlive the last
+ window.
+
+A provider selection change in settings rebuilds the service on the next start, which is how Voice
+Setup takes effect without an app restart.
+
+`submit-agent-reply` is the reply seam over the transport. Phase 05 feeds real agent output to
+`VoiceSessionService.submitAgentReply()` in-process, but nothing outside main can push a session
+past `dispatching` without this channel, and a session that never leaves `dispatching` never
+speaks. It is what the dev harness uses to demonstrate speech, barge-in, and the difference
+between barge-in and the stop word.
+
+### The renderer client
+
+`src/renderer/components/ACappella/` holds the desktop client, and it is deliberately thin:
+
+- `useVoiceSession(enabled)` owns the ONE subscription to `acappella:event`. A second subscriber
+ would apply every event twice, so the HUD is the only mount point and it renders the harness
+ itself.
+- `voiceSessionStore` is a pure projection of the stream. It derives state from the event rather
+ than from the return value of an IPC call, flags a `seq` gap instead of smoothing it over, and
+ refuses to let a late `get-state` catch-up rewind a projection the stream has already carried
+ past.
+- `VoiceHud` renders that projection: bound scope, state, a listening/speaking indicator that
+ differs in shape and not only in hue, the streaming transcript, and any provider substitution.
+ Closing it ENDS the session, because an open floor behind an invisible surface is a microphone
+ the user cannot see.
+
+## Invariants
+
+1. **`seq` is never reused and never goes backwards** within a voice session.
+2. **Clients hold no authoritative state.** Every client is a projection of the event stream.
+3. **Barge-in keeps the floor; stop releases it.** No event may blur the two.
+4. **An illegal state transition throws.** The transition table in `session-state.ts` is the only
+ place legality is defined.
+5. **No provider substitution is silent.** Every provider-bearing event names its provider id.
+6. **The union is closed.** Adding an event means adding it to `protocol.ts`, not stuffing an
+ extra field into an existing payload.
+
+## Related
+
+- [[system-overview]] - the service, tiers, and client model.
+- [[adr-001-webrtc-transport]] - why the phone's media leg is WebRTC.
+- [[adr-002-main-process-session]] - why the session is headless in main.
diff --git a/docs/architecture/acappella/wake-and-hotkeys.md b/docs/architecture/acappella/wake-and-hotkeys.md
new file mode 100644
index 0000000000..b02137b234
--- /dev/null
+++ b/docs/architecture/acappella/wake-and-hotkeys.md
@@ -0,0 +1,169 @@
+---
+type: reference
+title: A Cappella Wake Word, Stop Word, and Hotkeys
+created: 2026-08-15
+tags:
+ - acappella
+ - architecture
+ - wake-word
+ - hotkeys
+related:
+ - '[[system-overview]]'
+ - '[[voice-session-protocol]]'
+ - '[[model-manager]]'
+---
+
+# Wake Word, Stop Word, and the Global Hotkey Registry
+
+Three ways to open the floor, and one way to shut it.
+
+| Surface | Opens | Steals focus | Lives in |
+| ---------------------------- | ---------------------------------- | ------------ | ----------------------------------------- |
+| Wake word (global phrase) | Conductor-scoped session | No | `main/acappella/wake/wake-detector.ts` |
+| Wake word (per-agent phrase) | Session bound to that agent | No | same |
+| `voiceConductor` hotkey | Conductor-scoped session | No | `main/acappella/hotkeys/voice-hotkeys.ts` |
+| `voiceCurrentAgent` hotkey | Session bound to the focused agent | Yes | same |
+| Stop word | Nothing - it ENDS the session | No | `main/acappella/wake/stop-word.ts` |
+
+Every one of them routes through `audio/floor-control.ts`. That module already
+owns what a second press means, what a release means, and when an untouched
+microphone goes cold. Four surfaces re-deriving any of it would drift inside a
+week.
+
+## The privacy invariant
+
+**While only the wake detector is running, no audio frame reaches a hosted
+provider or leaves the process.** This holds whether the user picked Whisper or
+OpenAI for speech-to-text, and it is not a preference.
+
+It is enforced structurally rather than by discipline:
+
+- `WakePhraseScorer.tier` is the string literal `'local'`, not a
+ `VoiceProviderTier`. A cloud provider's tier is `'cloud'`, so a hosted scorer
+ is not assignable and the mistake is a type error.
+- `assertWakeScorerLocal()` re-checks at runtime, for anything that arrives
+ through a cast or across an IPC boundary.
+- `WakeDetector` has exactly ONE outward edge, `onWake`. It never sees a
+ provider, never holds a socket, and cannot be handed one.
+- `wake-detector.test.ts` feeds 200 frames past a hosted STT spy and a `fetch`
+ spy with neither called.
+
+## Wake word
+
+openWakeWord on `onnxruntime-node`, loaded through `native-loader.ts`. The chain
+is melspectrogram -> embedding -> one small classifier per phrase, and the
+detector's contract with the scorer is one 80 ms hop (1280 samples at 16 kHz) in,
+a score per phrase id out.
+
+`createOnnxWakeScorer()` returns null when the runtime or the model files are
+missing, which is the ordinary state of a machine that has not opted into
+hands-free. The detector then runs INERT and says so; the capability gate is
+where a missing model becomes a sentence the user can act on.
+
+Three orchestration properties the model does not provide:
+
+- **Per-phrase sensitivity.** A two-syllable agent name and "hey maestro" do not
+ false-fire at the same threshold. The score has to clear `1 - sensitivity`.
+- **Debounce.** One spoken phrase clears the threshold over several consecutive
+ windows; without a debounce each would be a session.
+- **Pre-roll.** The `WakeDetection` carries the audio around the phrase, drained
+ from the ring, so "Maestro, what's the status" does not reach the recogniser as
+ "...what's the status". Wire the detector to the AUDIO PIPELINE's ring so there
+ is one buffer rather than two.
+
+## Stop word versus barge-in
+
+The single most important distinction in this subsystem.
+
+| | Barge-in | Stop word |
+| -------------- | -------------------------------------- | ------------- |
+| Means | "stop talking, I am still here" | "we are done" |
+| Speech | cancelled | cancelled |
+| Floor | KEPT | released |
+| Microphone | stays open | closed |
+| Terminal state | `speaking -> interrupted -> listening` | `-> idle` |
+| Event | `barge-in` | `stop-word` |
+
+They are separate modules, separate events, separate settings, and separate HUD
+feedback, because every assistant that folded them together became one you cannot
+get rid of. `StopWordController` is handed a session interface with `hardStop`
+and deliberately WITHOUT `interrupt`, so it cannot reach barge-in even by
+accident.
+
+The stop word runs on the local detector specifically because it must be heard
+while TTS is speaking and while a cloud STT stream is open. It cannot depend on a
+transcript coming back from a remote engine: the answer would arrive after the
+thing it was meant to stop had finished.
+
+`armedPhrases(state, ...)` is the arming rule: wake phrases while the session is
+cold, stop phrases in every active state. Never both, or a wake phrase spoken
+mid-answer would stack a second session.
+
+## The global hotkey registry
+
+`src/main/global-hotkey-manager.ts` was one deliberate singleton for "show
+Maestro". It is now a `GlobalHotkeyRegistry` keyed by id, because with three
+hotkeys a shared failure path would mean losing "show Maestro" over a bad voice
+combo.
+
+Failure kinds, distinguished because the user's next move differs:
+
+- `invalid-accelerator` - the combo has no non-modifier key.
+- `maestro-conflict` - two Maestro hotkeys want the same combo. Detected here and
+ NAMED; left to Electron the second registration silently wins or loses by
+ platform.
+- `os-conflict` - another application owns it.
+- `register-error` - `globalShortcut.register` threw.
+
+Failures reach the renderer on `globalHotkey:registrationFailed`, now carrying
+the whole `GlobalHotkeyStatus` (id included) rather than a bare key array. The
+definitions - ids, labels, and defaults - live in `src/shared/global-hotkeys.ts`,
+which both `DEFAULT_SHORTCUTS` and the main-process registry read, so a hotkey the
+Settings list and the registry spelled differently cannot exist.
+
+## Tap versus hold
+
+Electron's `globalShortcut` fires on PRESS and never on release. `press-hold.ts`
+turns one press callback into tap / hold-start / hold-end by polling a key-state
+probe.
+
+**Today every platform returns null and the hotkeys report `tap-only`.** There is
+no way to read live key state from Electron's own API: macOS needs
+`CGEventSourceKeyState`, Windows `GetAsyncKeyState`, X11 `XQueryKeymap`, and all
+three mean a native module Maestro does not ship. Auto-repeat timing is not a
+substitute - the OS repeat delay is longer than any usable hold threshold.
+
+Rather than fake it, the detector says so: `describePressHoldCapability()` is
+rendered in the Voice Controls settings. A push-to-talk key that silently behaves
+like a toggle is a bug users blame themselves for.
+
+The seam is real, not decorative. `setKeyStateProbe()` is what a native module
+plugs into, and it is how both branches are tested. Surfaces that DO have a real
+release event - the HUD button, the Phase 10 phone button - never come through
+here; they call `FloorController.press()`/`release()` directly.
+
+## Settings
+
+Everything except the two key bindings lives under `controls` in the one
+`acappella` settings blob (`useVoiceControls`, read back by
+`readVoiceControlSettings()` in main). The bindings live in the ordinary
+`shortcuts` map, so the Shortcuts tab and the Voice Controls panel are two views
+of one value rather than two values that can disagree.
+
+The Voice Controls panel shows each hotkey's REAL registration state inline, and
+a Test button runs the wake detector with no session behind it so sensitivity can
+be tuned by saying the phrase instead of by guessing.
+
+## Files
+
+| File | Owns |
+| ------------------------------------------------------------------- | -------------------------------------------------- |
+| `src/shared/global-hotkeys.ts` | Hotkey ids, labels, defaults, status shape |
+| `src/shared/acappella/voice-controls.ts` | Shipped phrases and timing numbers, both processes |
+| `src/main/global-hotkey-manager.ts` | `GlobalHotkeyRegistry` |
+| `src/main/acappella/hotkeys/press-hold.ts` | Tap vs hold classification |
+| `src/main/acappella/hotkeys/voice-hotkeys.ts` | The two hotkeys' semantics |
+| `src/main/acappella/hotkeys/index.ts` | Electron / settings wiring |
+| `src/main/acappella/wake/wake-detector.ts` | openWakeWord detector and the ONNX scorer |
+| `src/main/acappella/wake/stop-word.ts` | Stop phrases, arming rule, teardown |
+| `src/renderer/components/Settings/ACappella/VoiceControlsPanel.tsx` | The settings surface |
diff --git a/docs/docs.json b/docs/docs.json
index 22e1c7477d..a14fa0708e 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -80,6 +80,7 @@
"icon": "flask",
"pages": [
"encore-features",
+ "voice-mode",
"director-notes",
"usage-dashboard",
"symphony",
diff --git a/docs/encore-features.md b/docs/encore-features.md
index ee5c56e109..7d06da2621 100644
--- a/docs/encore-features.md
+++ b/docs/encore-features.md
@@ -18,6 +18,7 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t
| Feature | Shortcut | Description |
| ------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
+| [A Cappella](./voice-mode) | `Cmd+Alt+V` / `Ctrl+Alt+V` | Voice interface (Beta): speak a request, have it routed to an agent, and hear the reply read back |
| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses |
| [Usage Dashboard](./usage-dashboard) | `Opt+Cmd+U` / `Alt+Ctrl+U` | Comprehensive analytics for tracking AI usage patterns |
| [Maestro Symphony](./symphony) | `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Contribute to open source by donating AI tokens |
diff --git a/docs/ios-client/app-store-review.md b/docs/ios-client/app-store-review.md
new file mode 100644
index 0000000000..a1e87e3ef0
--- /dev/null
+++ b/docs/ios-client/app-store-review.md
@@ -0,0 +1,231 @@
+---
+type: specification
+title: App Store Review
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[background-and-entitlements]]'
+ - '[[connection-and-pairing]]'
+ - '[[project-structure]]'
+---
+
+# App Store Review
+
+Three things about this app make review harder than the code does: it asks for the microphone, it
+asks for a restricted entitlement, and it does nothing at all without a Mac the reviewer does not
+have. Each has a specific answer, and each answer has to exist before the first submission rather
+than after the first rejection.
+
+## Usage description copy
+
+These strings are read by a human in a permission dialog at the moment they decide whether to
+trust the app. Write them as answers to "why", not as restatements of "what".
+
+### Microphone
+
+```
+NSMicrophoneUsageDescription
+```
+
+> Maestro sends your voice to the computer you paired with, so you can talk to your agents from
+> across the room. Audio is captured only while you are holding the talk button.
+
+Why each half is there:
+
+- **"the computer you paired with"** names the destination. A microphone prompt that does not say
+ where the audio goes is the prompt people deny.
+- **"only while you are holding the talk button"** is a commitment the code keeps
+ (see [[audio-session]]) and the OS enforces on the Push to Talk path
+ (see [[background-and-entitlements]]). Do not write it unless both remain true.
+
+If the wake word ships enabled in a build, the second sentence has to change to include it, and
+the wake-word toggle itself must carry the longer explanation. A usage string that describes a
+narrower behaviour than the app performs is a 5.1.1 rejection and, worse, is a lie.
+
+### Camera
+
+```
+NSCameraUsageDescription
+```
+
+> Maestro scans the pairing code shown on your computer. The camera is used only for that scan.
+
+### Local network
+
+```
+NSLocalNetworkUsageDescription
+```
+
+> Maestro finds your computer on this network so you can pair without typing an address.
+
+The local-network prompt is the one users find most alarming, because iOS presents it in stark
+terms. Two mitigations, both worth building:
+
+- **Do not trigger it at launch.** Ask only when the user taps "Find my Mac", so the prompt arrives
+ attached to an action they just took.
+- **Offer the QR path first**, which needs no local-network permission at all. A user who scans a
+ code never sees the prompt.
+
+### Speech recognition
+
+**Not requested.** `NSSpeechRecognitionUsageDescription` must not appear in `Info.plist`. There is
+no on-device Apple speech recognition in this app: the desktop transcribes. Requesting a permission
+the app does not use is both a rejection risk and a privacy claim we do not need to make.
+
+## Privacy nutrition labels
+
+The honest position, which also happens to be the simplest label:
+
+| Data type | Collected? | Notes |
+| --------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Audio Data | **No** | Audio is sent peer to peer to the user's own paired computer over an encrypted WebRTC connection. It is not sent to Maestro's servers and it is not retained anywhere by this app. |
+| Identifiers | **No** | The device name and a device identifier are sent to the paired computer only, and stored only on that computer. |
+| Usage Data, Diagnostics | **No** | v1 ships with no analytics SDK and no crash reporter. See below. |
+| Contacts, Location, Photos, Health, Financial | **No** | Not accessed. |
+
+Two places this needs care:
+
+**The TURN relay.** If a build ships with a default TURN server operated by us, encrypted media
+packets transit it when a direct path cannot be established. That is a transient relay of
+end-to-end encrypted data that is not retained and not readable by the operator, which does not
+meet Apple's definition of collection (transmitting off device in a way that allows access for
+longer than necessary to service the request in real time). It is still worth a sentence in the
+privacy policy, and the app already tells the user when it is happening: the connection indicator
+says "Relayed" in words (see [[connection-and-pairing]]).
+
+**The desktop's providers.** A user can configure the desktop to transcribe with OpenAI or speak
+with ElevenLabs. In that configuration the user's audio does reach a third party. That happens on
+the user's own machine, under settings the user set there, and the desktop states it in one
+sentence computed from the live configuration (`mic-state.egressStatement`, never hard-coded copy).
+The iOS app must:
+
+- **Display that sentence verbatim** on its paired-device sheet, so a phone user can see where
+ their audio goes without walking to the desk.
+- **Not claim in its own privacy label that it sends audio to third parties**, because it does not.
+ It sends audio to one computer. What that computer does next is that computer's disclosure, and
+ it makes it.
+
+**Analytics.** Ship v1 with none. It turns the entire label into "Data Not Collected", which is
+worth more than the funnel data would be, and it removes a whole class of review questions. If a
+crash reporter is added later, it becomes "Diagnostics, not linked to identity, app functionality",
+and this document gets updated in the same commit.
+
+## The reviewer problem
+
+**The app is useless without a paired desktop.** A reviewer who installs it and opens it sees a
+pairing screen and can go no further. Left unaddressed, that is a Guideline 2.1 rejection
+(App Completeness) and possibly a 4.2 one (Minimum Functionality).
+
+Four things, in the order they matter.
+
+### 1. Make the unpaired state a real screen
+
+The first launch experience must be worth reading even for someone with no Mac:
+
+- One screen explaining what the app is: a remote microphone for Maestro on your computer.
+- The three ways to connect, with the QR path first.
+- A link to the Maestro documentation and to the desktop download.
+- **No dead ends.** Never a spinner, never a blank list, never a "connect to continue" wall with
+ nothing behind it.
+
+An app that explains itself clearly to someone who cannot use it is not an incomplete app. It is a
+companion app, which is a category Apple accepts, and the screen is what makes that legible.
+
+### 2. Provide a live demo desktop
+
+The best outcome is a reviewer who actually uses the app. Provide it:
+
+- Run a Mac with Maestro and A Cappella enabled, reachable over the internet through the TURN
+ relay, with two or three agents in the roster doing recognisable work.
+- In App Review Information, include a **pairing QR image** and the six-character code, plus the
+ host and port for manual entry as a fallback.
+- The demo desktop runs with pairing auto-approval enabled for the review window, because the
+ approval step needs a human at the desk and there will not be one at 03:00 in Cupertino.
+- **Disclose the auto-approval in the review notes.** It is a configuration of the demo machine,
+ not a hidden feature of the app, and saying so is what keeps it from looking like one under
+ Guideline 2.3.1.
+- Rotate the credentials and turn the demo machine off after the review.
+
+### 3. Provide a demo video regardless
+
+Networks fail, review happens at odd hours, and a demo desktop that is asleep is worse than none.
+Attach a video, 60 to 90 seconds, screen recording of the phone with the Mac visible:
+
+1. Scan the QR code, approve on the Mac, connected.
+2. The project wheel populates with real agents.
+3. Hold the button, say "open a tab on the backend agent about the auth refactor".
+4. The tab appears on the Mac. The reply is spoken on the phone.
+5. Talk over the reply; it stops mid-sentence.
+6. Say the stop word; the session ends.
+7. Lock the phone, transmit from the Push to Talk system control, show it still works.
+
+Step 7 exists for the entitlement reviewer specifically, and it is the one to lead with in the
+notes when the Push to Talk entitlement is under review.
+
+### 4. Write the review notes as if the reviewer has five minutes
+
+```
+WHAT THIS APP IS
+Maestro is a desktop app for macOS and Windows that runs AI coding agents. This iOS app is a
+remote microphone and speaker for it: you talk to your computer from across the room. It does
+not run any AI on the phone and it requires a paired computer.
+
+HOW TO TEST IT (demo computer provided)
+1. Open the app, tap Scan.
+2. Scan the attached QR image (also: code 7K2MBX, host , port ).
+3. The demo computer auto-approves during this review window (a configuration of our demo
+ machine, not a feature of the app; a normal user approves each device by hand).
+4. Hold the large Talk button and say: "what are you working on".
+5. The reply is spoken back on the phone. Tap the button while it speaks to interrupt.
+
+PERMISSIONS
+Microphone: audio is sent over an encrypted WebRTC connection to the paired computer only.
+Camera: pairing QR scan only.
+Local network: optional Bonjour discovery, only after the user taps "Find my Mac".
+
+PUSH TO TALK ENTITLEMENT
+Used for its designed purpose. The user joins a channel for their paired computer, transmits
+while holding the button or the system control, and the microphone is closed at all other times.
+See the attached video from 0:52.
+```
+
+## Other review surface
+
+- **Guideline 4.2 (Minimum Functionality).** The counter-argument is that this is a companion app
+ to a shipping desktop product with real users, in the same category as a remote or a companion
+ for hardware. The unpaired-state screen from step 1 is the evidence. Have a link to
+ https://maestro.sh and the docs in the notes.
+- **Guideline 2.5.4 (background audio).** Only relevant if a build declares `UIBackgroundModes:
+audio`. It should not. See [[background-and-entitlements]].
+- **Guideline 5.1.5 (location).** Not applicable; no location APIs.
+- **Age rating.** 4+. No user-generated content is displayed by the app itself, and the agent
+ replies are the user's own machine talking to them.
+- **Account requirement.** None. Guideline 5.1.1(v) is satisfied trivially: there is no sign-in, no
+ email collected, and no account to delete. Say so in the notes so nobody looks for one.
+- **Export compliance.** The app uses DTLS-SRTP through WebRTC and TLS for signaling. Confirm the
+ exemption applies to that use before setting `ITSAppUsesNonExemptEncryption` to `false`, and file
+ the self-classification report if it does not. Do not copy the flag from another project.
+- **Privacy policy URL.** Required because a permission-gated microphone is involved. It must
+ contain the same two claims this document makes: audio goes to the user's paired computer, and
+ onward only to the provider the user configured on that computer.
+
+## Submission checklist
+
+- [ ] `NSMicrophoneUsageDescription`, `NSCameraUsageDescription`, `NSLocalNetworkUsageDescription`
+ present, and each matches the copy above.
+- [ ] `NSSpeechRecognitionUsageDescription` **absent**.
+- [ ] `NSBonjourServices` contains `_maestro._tcp`.
+- [ ] `UIBackgroundModes` contains `push-to-talk` and **not** `audio`.
+- [ ] `com.apple.developer.push-to-talk` granted and present in the provisioning profile.
+- [ ] Privacy nutrition labels filled in as "Data Not Collected", matching the analytics decision
+ actually shipped in this build.
+- [ ] Privacy policy URL live and containing the two audio-destination claims.
+- [ ] Demo desktop running, auto-approval on, credentials fresh, disclosed in the notes.
+- [ ] Demo video attached, including the locked-screen Push to Talk segment.
+- [ ] Review notes pasted, with the QR image attached.
+- [ ] Unpaired first-launch screen reachable with no network at all, and useful there.
+- [ ] Export compliance answered deliberately rather than copied.
diff --git a/docs/ios-client/audio-session.md b/docs/ios-client/audio-session.md
new file mode 100644
index 0000000000..f2561ccee3
--- /dev/null
+++ b/docs/ios-client/audio-session.md
@@ -0,0 +1,224 @@
+---
+type: specification
+title: Audio Session and WebRTC Configuration
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[connection-and-pairing]]'
+ - '[[interaction-model]]'
+ - '[[background-and-entitlements]]'
+ - '[[../architecture/acappella/latency-baseline]]'
+---
+
+# Audio Session and WebRTC Configuration
+
+This is the file that decides whether the app is usable. Everything else can be rebuilt from a
+screenshot; an audio session configured wrong produces echo, a microphone that dies when AirPods
+connect, or a call that silently kills the session, and none of those look like configuration
+problems from the outside.
+
+## The one thing to get right
+
+**`AVAudioSession` in `.playAndRecord` category with `.voiceChat` mode.**
+
+That mode is what routes capture through Apple's Voice-Processing I/O audio unit, which gives
+hardware acoustic echo cancellation, noise suppression, and automatic gain control tuned by people
+with access to the microphone geometry of every iPhone ever shipped. It is the reason a phone
+placed on a desk with its speaker playing a synthesised reply does not send that reply straight
+back as a new utterance.
+
+The desktop cannot do this for us. It asks for it (`requestRemoteEchoCancellation: true` in the
+`audio` config from `authenticated`) because the reference signal for cancelling the phone's echo
+is the phone's own speaker output, and only the phone has it. The desktop applies its own AEC to
+its own microphone, where it works.
+
+## Session configuration
+
+```swift
+let session = AVAudioSession.sharedInstance()
+
+try session.setCategory(
+ .playAndRecord,
+ mode: .voiceChat,
+ options: [.allowBluetooth, .defaultToSpeaker, .allowBluetoothA2DP]
+)
+try session.setPreferredSampleRate(48_000) // Opus native rate
+try session.setPreferredIOBufferDuration(0.02) // one 20 ms Opus frame
+try session.setActive(true)
+```
+
+Each choice, and what breaks without it:
+
+| Setting | Reason |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `.playAndRecord` | Duplex. `.record` gives no playback, `.playback` gives no microphone. |
+| `.voiceChat` mode | The whole point. Enables VPIO, hence hardware AEC. Without it the phone hears itself. |
+| `.allowBluetooth` | HFP, which is the only Bluetooth profile with an input path. Without it, connecting AirPods loses the microphone and the user cannot tell why. |
+| `.defaultToSpeaker` | Otherwise `.playAndRecord` routes output to the earpiece receiver, and a phone on a desk sounds like it is broken. |
+| `.allowBluetoothA2DP` | Lets output-only Bluetooth devices carry playback at full quality when nothing needs the HFP input path. |
+| `setPreferredSampleRate(48000)` | Opus is native at 48 kHz. A resample on the way in is latency and quality thrown away. The system may not honour it (VPIO frequently runs at 24 kHz); read back `session.sampleRate` and do not assume. |
+| `setPreferredIOBufferDuration(0.02)` | Matches the Opus frame size, so a capture callback fills exactly one packet. Lower buffers cost CPU and gain nothing over the network jitter budget in [[../architecture/acappella/latency-baseline]]. |
+
+**Do not** set `.mixWithOthers`. A voice assistant that ducks under a podcast rather than taking
+the audio route is one you cannot hear when it matters.
+
+## WebRTC.framework configuration
+
+libwebrtc on iOS owns its own audio unit and will fight `AVAudioSession` if both are configured
+independently. `RTCAudioSession` is the shared lock that stops that.
+
+```swift
+let rtc = RTCAudioSession.sharedInstance()
+rtc.lockForConfiguration()
+defer { rtc.unlockForConfiguration() }
+
+// We decide when the microphone runs, not the peer connection lifecycle.
+rtc.useManualAudio = true
+rtc.isAudioEnabled = false // flipped true only while the floor is open
+
+let config = RTCAudioSessionConfiguration.webRTC()
+config.category = AVAudioSession.Category.playAndRecord.rawValue
+config.mode = AVAudioSession.Mode.voiceChat.rawValue
+config.categoryOptions = [.allowBluetooth, .defaultToSpeaker, .allowBluetoothA2DP]
+RTCAudioSessionConfiguration.setWebRTC(config)
+```
+
+Three requirements:
+
+- **`useManualAudio = true` is mandatory**, not an optimisation. It is what lets the microphone be
+ off while the peer connection is up, which is the entire privacy story: a paired phone sitting
+ in a pocket has a live connection and a cold microphone. Without it, libwebrtc starts capture as
+ soon as a sending transceiver exists.
+- **Never call `setActive` on `AVAudioSession` directly while `RTCAudioSession` holds the lock.**
+ Route through `RTCAudioSession`'s own `setActive`/`setCategory` so the two agree on state.
+- **Do not enable libwebrtc's software echo canceller.** On iOS the VPIO unit already cancelled;
+ running the software APM on top of it produces gating artifacts on the far end that sound like a
+ bad connection. Verify with the double-talk test below rather than trusting either default.
+
+### Opus parameters
+
+The desktop sends a `RemoteAudioConfig` in the `authenticated` message:
+
+```ts
+{ fec: true, dtx: true, maxAverageBitrate: 24000, requestRemoteEchoCancellation: true }
+```
+
+Apply them to the outgoing `opus/48000/2` `fmtp` line in the local offer:
+
+| Config field | SDP / API |
+| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `fec: true` | `useinbandfec=1`. This is what survives 5 percent loss on cellular. |
+| `dtx: true` | `usedtx=1`. Stops sending during silence, which is most of a session, and lets the radio sleep. |
+| `maxAverageBitrate: 24000` | `maxaveragebitrate=24000`. Speech at 24 kbps in Opus is transparent enough to route on. |
+| `requestRemoteEchoCancellation` | Already satisfied by `.voiceChat`. If it ever arrives `false`, still keep `.voiceChat`: the desktop is asking us to skip processing, not telling us to send it echo. |
+
+Prefer mono (`stereo=0`, `sprop-stereo=0`). A speech pipeline downmixes anyway, and the second
+channel is bitrate spent on nothing.
+
+**Never send more than one audio track.** The desktop gates exactly one remote microphone into the
+capture pipeline at a time (`set-floor-holder`); a second track is received and discarded.
+
+## Route changes
+
+Subscribe to `AVAudioSession.routeChangeNotification` and act on the reason, not on the fact that
+something changed:
+
+| `AVAudioSession.RouteChangeReason` | Situation | Behaviour |
+| ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `.newDeviceAvailable` | AirPods or a headset connected | Let it take the route. Update the output label in the UI. If the floor is open, keep it open: the user connected headphones mid-sentence, they did not ask to stop. |
+| `.oldDeviceUnavailable` | AirPods removed, cable unplugged | Apple's convention is to pause. **Close the floor** and send `floor: release`. A microphone that follows a yanked headset back to the phone's built-in mic without telling anyone is a privacy failure. |
+| `.categoryChange` | Another app or the system changed the category | Re-apply our configuration. If re-application fails, surface it: the session is no longer what we think it is. |
+| `.override` | Speaker/receiver override | Update the UI label only. |
+| `.routeConfigurationChange` | Same route, different config | Read back `sampleRate` and `ioBufferDuration`; log if they moved. |
+
+Specific routes worth naming:
+
+- **AirPods and Bluetooth headsets** use HFP for duplex, which is 16 kHz wideband on current
+ firmware and 8 kHz narrowband on older devices. Both are below Opus's native rate. This is
+ acceptable and must not be treated as an error, but the quality indicator's tooltip should be
+ able to say the input is a Bluetooth headset when a user asks why transcription got worse.
+- **CarPlay** presents as a route like any other. The app must work there, but the push-to-talk
+ button is not reachable while driving, so CarPlay is precisely the case that needs the wake word
+ and the Push to Talk framework path in [[background-and-entitlements]]. Do **not** ship a CarPlay
+ UI target in the first version; it is on the non-goal list in [[project-structure]].
+- **Wired headsets** behave like the built-in route with a different microphone. Nothing special.
+
+## Interruptions
+
+`AVAudioSession.interruptionNotification` is the one that decides whether an incoming phone call
+leaves the app in a sane state.
+
+```
+.began
+ -> close the floor immediately (the OS already took the microphone)
+ -> send `floor: release` if we held it, and `interrupt: stop-word` is NOT sent:
+ the user did not end the conversation, the phone did
+ -> keep the peer connection and the data channel alive; the roster and transcript
+ stay on screen so returning from the call resumes a conversation rather than
+ restarting one
+ -> mark the mic button "Interrupted"
+
+.ended
+ -> if options contains .shouldResume: re-activate the session and re-apply the
+ RTCAudioSessionConfiguration, then return the button to its idle state
+ -> do NOT re-open the floor automatically. A microphone that opens itself after
+ a phone call is the worst possible failure mode for a microphone app.
+```
+
+Also handle:
+
+- **`AVAudioSession.mediaServicesWereResetNotification`.** The audio server died. Every
+ `AVAudioSession`, `AVAudioEngine`, and audio unit reference is now invalid. Tear down the local
+ capture graph and the `RTCAudioSession` configuration and rebuild both from scratch. This is
+ rare and it is unrecoverable if handled by anything less than a full rebuild.
+- **`.mediaServicesWereLostNotification`.** Stop everything and wait for the reset notification.
+- **Siri.** Arrives as an ordinary interruption. Nothing special beyond the above.
+
+## Microphone gating
+
+Two independent gates, because they answer two different questions.
+
+**Gate 1: is the peer connection carrying audio?** `RTCAudioSession.isAudioEnabled`, plus
+`sender.track?.isEnabled`. Both off until the floor is open. This is the gate that satisfies
+"no audio leaves this device before the floor is open".
+
+**Gate 2: is anything capturing at all?** With `useManualAudio = true` and audio disabled, the
+WebRTC audio unit is not running. If the app also runs on-device wake-word detection, that
+capture is a **separate, local-only** `AVAudioEngine` tap whose buffers never reach an encoder and
+never leave the process. It exists so the phone can hear "hey maestro"; it is the one capture that
+runs with the floor closed, and it is why the orange microphone indicator can be lit while nothing
+is being transmitted. The UI must say which of the two states it is in, in words, in the HUD. See
+[[interaction-model]].
+
+## Playback
+
+The desktop's TTS arrives as a remote audio track. Requirements:
+
+- **Attenuate, do not stop, on local VAD.** When the on-device detector hears speech while the
+ remote track is playing, duck playback to roughly 20 percent within 20 ms and send
+ `interrupt: barge-in`. The authoritative cancellation comes back as a `barge-in` voice event
+ once the desktop has actually stopped generating; restore or stop playback then. Ducking first
+ is what makes barge-in feel instant even though the round trip is not.
+- **Do not buffer ahead.** WebRTC's jitter buffer is the buffer. A second one on top of it adds
+ latency to a system whose entire budget is documented in
+ [[../architecture/acappella/latency-baseline]].
+- **`speak-end` is the end of speech, not the end of the track.** Do not tear down the audio unit
+ between replies; the reconfiguration cost lands on the front of the next one.
+
+## Verification
+
+An audio session cannot be reviewed by reading it. Before any release:
+
+1. **Double-talk test.** Phone on a desk, speaker at 80 percent, desktop reading a long reply.
+ Talk over it. The desktop must receive your words and must not receive its own voice. If the
+ transcript contains fragments of the reply, AEC is not engaged and the mode is wrong.
+2. **AirPods mid-sentence.** Open the floor, speak, connect AirPods. Capture must survive.
+3. **AirPods removed mid-sentence.** Floor must close and the desktop must be told.
+4. **Incoming call during a reply.** Session survives, floor closes, nothing resumes by itself.
+5. **CarPlay connect and disconnect** while the floor is open.
+6. **Airplane mode toggle** while speaking, to exercise the reconnect path in
+ [[connection-and-pairing]] against a live audio session.
diff --git a/docs/ios-client/background-and-entitlements.md b/docs/ios-client/background-and-entitlements.md
new file mode 100644
index 0000000000..d3a3f97b31
--- /dev/null
+++ b/docs/ios-client/background-and-entitlements.md
@@ -0,0 +1,194 @@
+---
+type: specification
+title: Background Audio and Entitlements
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[audio-session]]'
+ - '[[app-store-review]]'
+ - '[[project-structure]]'
+---
+
+# Background Audio and Entitlements
+
+The problem this document solves: a user puts the phone on the desk, walks to the whiteboard, and
+talks. The screen locks. On iOS, that is the end of the microphone unless the app has told the
+system, in advance and in a way Apple sanctions, what it is.
+
+There are exactly two sanctioned answers and one tempting wrong one.
+
+## The clean answer: the Push to Talk framework (iOS 16+)
+
+`PushToTalk` was built for walkie-talkie apps, and A Cappella is one. The phone joins a channel,
+the system shows a persistent indicator and its own transmit affordance, and the app is allowed to
+capture audio in the background **while transmitting and only while transmitting**.
+
+That restriction is not an obstacle. It is the same rule the app already enforces: the floor is
+open or it is not, and no audio leaves the device when it is not. The framework enforces in the OS
+what [[audio-session]] enforces in our code, which means the two cannot drift.
+
+### What it requires
+
+| Requirement | Detail |
+| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Minimum deployment target | **iOS 16.0.** This is the single constraint that sets the app's floor. See [[project-structure]]. |
+| Entitlement | `com.apple.developer.push-to-talk`. **Restricted**: it must be requested from Apple through the developer account and granted before it can be added to a provisioning profile. Assume weeks, not hours. |
+| Background mode | `UIBackgroundModes` must contain `push-to-talk`. |
+| Push credentials | An APNs key configured for the `pushtotalk` push type, if the desktop should be able to wake a backgrounded phone. |
+| Framework ownership of the audio session | The app must **not** activate `AVAudioSession` itself while in a channel. |
+
+### What it grants
+
+- **Background microphone access while transmitting.** Screen locked, app not foreground, floor
+ open: audio flows.
+- **A system UI the user cannot lose.** iOS shows the channel in the status bar and Dynamic Island
+ with a transmit control, so the user can talk and stop talking without unlocking the phone. This
+ is the actual feature; our in-app button becomes the secondary path, not the primary one.
+- **A wake path from the desktop.** A `pushtotalk` PushKit notification can bring the app back into
+ a live channel state without the user touching anything.
+- **Battery behaviour the system understands**, rather than an app fighting the scheduler.
+
+### What Apple expects in return
+
+Read this as a contract, because review treats it as one:
+
+1. **The app is genuinely push-to-talk.** There is a transmit gesture, a clear start and end, and
+ the microphone is closed the rest of the time. A Cappella is exactly this; do not add a
+ continuous-listening mode to the framework path.
+2. **The channel is joined in response to a user action** and left when the user is done. Joining
+ at launch and never leaving is the pattern Apple rejects.
+3. **The framework owns the audio session.** Implement
+ `channelManager(_:didActivate:)` and `channelManager(_:didDeactivate:)` and do the WebRTC
+ configuration from [[audio-session]] inside those callbacks. Calling `setActive(true)` yourself
+ while in a channel is undefined behaviour and, in practice, silence.
+4. **The system UI is authoritative.** When the user transmits from the status bar control,
+ `channelManager(_:didBeginTransmittingFrom:)` fires and we send `floor: press`. When they stop,
+ we send `floor: release`. The in-app button and the system control drive the same code path.
+5. **Leaving the channel means leaving.** `leaveChannel` on user request, on revocation, and on a
+ terminal disconnect. A stale channel indicator on a user's status bar for an app that is not
+ connected to anything is the complaint that gets an entitlement pulled.
+
+### Mapping onto our protocol
+
+```
+PTChannelManagerDelegate A Cappella
+------------------------------------------------ ---------------------------------
+didActivate audioSession -> configure RTCAudioSession, isAudioEnabled = true
+didBeginTransmittingFrom: .unknown/.userRequest-> send { type: 'floor', action: 'press', scope }
+didEndTransmittingFrom: -> send { type: 'floor', action: 'release' }
+didDeactivate audioSession -> isAudioEnabled = false, teardown capture
+channelDescriptor -> name: the paired desktop's name
+ image: the selected agent's glyph
+incomingPushResult -> reconnect signaling, re-auth, re-offer
+```
+
+`floor-state` from the desktop still drives the UI. If another device takes the floor while we are
+transmitting, stop the transmission through `stopTransmitting(channelUUID:)` so the system UI
+agrees with the app.
+
+### What it does not grant
+
+**Not a background wake word.** The framework gives the microphone during transmission, and the
+wake word by definition runs before transmission. There is no supported way to run continuous
+background capture for keyword spotting, and there should not be one.
+
+So: **the wake word works while the app is foregrounded, and only then.** Say that in the app, next
+to the wake-word toggle, in one sentence. A user who thinks the phone is listening for "hey
+maestro" in their pocket and finds it is not will conclude the feature is broken rather than
+constrained, and they will be right to.
+
+## The alternative: foregrounded with a dimmed screen
+
+For iOS 15, for a user who does not want the system channel indicator, and as the fallback while
+the entitlement request is pending:
+
+```swift
+UIApplication.shared.isIdleTimerDisabled = true
+// plus a large dark UI, and an explicit "Keep awake" toggle the user controls
+```
+
+The app stays foreground, the audio session stays active, and everything in [[audio-session]]
+works unchanged. It requires no entitlement and no review conversation.
+
+The cost is real and must be stated in the UI rather than discovered:
+
+| Draw | Rough order of magnitude, to be measured before shipping |
+| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Opus at 24 kbps with DTX, over WiFi | Low. The radio is the smaller half of this. |
+| Cellular instead of WiFi | Noticeably worse, because DTX lets the radio sleep less often than the periodic data channel traffic allows. |
+| Screen on at minimum brightness | **Dominates everything else.** On an OLED device a near-black UI is much cheaper than a light one, which is why the keep-awake screen is specified black. |
+
+Those are shapes, not measurements. Before shipping, run the device for a measured hour in each of
+three states (idle connected, floor open on WiFi, floor open on cellular) with Xcode's Energy
+gauge, and put the real numbers in this table. Shipping an estimate as if it were a measurement is
+how a battery complaint becomes a surprise.
+
+Mitigations that are worth building on this path:
+
+- A dedicated **keep-awake screen**: pure black, the talk button, the level meter, nothing else.
+- **Auto-release the idle timer** whenever the floor has been closed for 5 minutes, and say so.
+- **Disable it on Low Power Mode** (`ProcessInfo.processInfo.isLowPowerModeEnabled`), with a
+ visible explanation, and re-enable when it clears.
+
+## The tempting wrong answer: `UIBackgroundModes: audio`
+
+Declaring the `audio` background mode and keeping a `.playAndRecord` session alive does keep the
+microphone running with the screen off. Do not ship it as the primary path.
+
+- **It is not what the mode is for.** The `audio` mode is for playback and for recording apps whose
+ recording is the user-visible product. Review reads a background `audio` declaration on a
+ microphone app as continuous background recording and asks hard questions, and the answers are
+ worse to give than the Push to Talk entitlement is to request.
+- **It gives the OS no reason to protect the app.** Under memory pressure a background audio
+ session is a candidate for termination in a way a PTT channel is not.
+- **It removes the OS-level guarantee** that the microphone is closed when the floor is closed. The
+ same guarantee then rests entirely on our own gating, with no second enforcement.
+
+There is one legitimate use: a **debug build** flag, so protocol work can happen before the
+entitlement arrives. Keep it out of any configuration that can be archived for distribution.
+
+Also on the do-not list: **CallKit and VoIP PushKit**. Reporting an A Cappella session as a call to
+keep the microphone alive misrepresents the app to the OS and to the user's call history, and a
+VoIP push that does not report a call to CallKit terminates the app by design.
+
+## `Info.plist` and entitlements summary
+
+```xml
+
+NSMicrophoneUsageDescription
+Maestro sends your voice to the computer you paired with, so you can talk to your agents from across the room. Audio is captured only while you are holding the talk button.
+
+NSCameraUsageDescription
+Maestro scans the pairing code shown on your computer. The camera is used only for that scan.
+
+NSLocalNetworkUsageDescription
+Maestro finds your computer on this network so you can pair without typing an address.
+
+NSBonjourServices
+_maestro._tcp
+
+UIBackgroundModes
+push-to-talk
+```
+
+```xml
+
+com.apple.developer.push-to-talk
+
+aps-environment
+production
+```
+
+The exact copy for the usage descriptions, and why each sentence is worded the way it is, is in
+[[app-store-review]].
+
+## Decision, stated once
+
+**Ship the Push to Talk framework path, minimum iOS 16, with the dimmed-screen keep-awake mode as
+a user-selectable alternative on the same build.** Request the entitlement on day one of the Swift
+effort, because it is the longest-lead item in the whole project and everything else can be built
+against the keep-awake path while it is pending.
diff --git a/docs/ios-client/connection-and-pairing.md b/docs/ios-client/connection-and-pairing.md
new file mode 100644
index 0000000000..242b13b13f
--- /dev/null
+++ b/docs/ios-client/connection-and-pairing.md
@@ -0,0 +1,276 @@
+---
+type: specification
+title: Connection and Pairing
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[protocol-conformance]]'
+ - '[[audio-session]]'
+ - '[[../architecture/acappella/transport-and-pairing]]'
+---
+
+# Connection and Pairing
+
+The desktop side of everything here is specified in
+[[../architecture/acappella/transport-and-pairing]] and implemented in
+`src/main/acappella/pairing/pairing-service.ts` and
+`src/main/acappella/transport/signaling.ts`. This document is the client half.
+
+## The security model the client must not undermine
+
+Four properties the desktop enforces. The client's job is to never work around any of them, and
+to explain them honestly when they bite:
+
+1. **Knowing the pairing code is not enough.** A code is a pointer to a request, not an
+ authorisation. Pairing completes only when a human presses Approve on the desktop, looking at
+ the name and platform this app reported. There is no client-side path that skips that, and the
+ app must not imply there is.
+2. **The window is short and one-shot.** A code lives 120 seconds and is consumed by the first
+ claim, approved or denied. Expiry is normal, not an error state to apologise for: show
+ "Ask the desktop for a new code" and a countdown.
+3. **The token is never recoverable from the desktop.** The desktop stores only a salted SHA-256.
+ If the phone loses its Keychain item, the only path back is a fresh pairing. Never build a
+ "recover my token" flow, because the desktop cannot serve one.
+4. **Revocation is immediate.** A `revoked` message on the data channel, or an `auth-failed` on
+ the signaling socket, is final. The client must delete its Keychain item and return to the
+ unpaired state on either. Retrying a revoked token is the one thing that turns a clean
+ revocation into a support ticket.
+
+## Transport summary
+
+| Leg | Carries | Where |
+| --------- | ----------------------------------------- | ------------------------------------------- |
+| Signaling | Pairing, auth, SDP, ICE candidates | `ws://://ws` |
+| Media | Opus, both directions | WebRTC peer connection |
+| State | Roster, tabs, floor, errors, revocation | `RTCDataChannel` labelled `acappella-state` |
+| Realtime | Levels, partials, press/release, barge-in | `RTCDataChannel` labelled `acappella-live` |
+
+Every signaling frame is a JSON object `{ "type": "acappella_signal", "payload": }`. The
+`payload` shapes are `SignalingClientMessage` and `SignalingServerMessage` in
+`src/main/acappella/transport/signaling.ts`; they are enumerated exhaustively in
+[[protocol-conformance]].
+
+## Finding a desktop
+
+Three paths, in the order the UI should offer them.
+
+### 1. QR code (the primary path)
+
+The desktop's Settings panel (Encore Features -> A Cappella -> Paired Devices) renders a QR code.
+Its payload is a JSON object:
+
+```json
+{
+ "kind": "maestro-acappella",
+ "v": 1,
+ "hosts": ["192.168.1.42", "100.83.11.9"],
+ "port": 17173,
+ "token": "",
+ "code": "K7QMBX",
+ "expiresAt": 1786820745000,
+ "fingerprint": "9F3A"
+}
+```
+
+Client requirements:
+
+- **Reject anything whose `kind` is not `maestro-acappella`.** A QR scanner that tries to make
+ sense of an arbitrary payload is an attack surface.
+- **Reject a `v` this build does not implement**, with the sentence "This Maestro is newer than
+ this app. Update the app." Do not attempt a best-effort parse.
+- **Treat `expiresAt` as authoritative.** A payload already past it must not be sent to the
+ desktop; say the code expired and ask for a new one.
+- **Try every entry in `hosts`, in order, in parallel, and take the first socket that opens.**
+ `hosts` is ordered with the desktop's primary interface first, and it can contain overlay
+ addresses (Tailscale allocates from `100.64.0.0/10`) that work from anywhere. A phone that only
+ tries `hosts[0]` fails on any Mac with more than one interface, which is most of them.
+- **Show the `fingerprint` on the phone** after connecting, next to the same four characters shown
+ on the desktop. It is derived from the server token, so a matching pair means the phone is
+ talking to the machine whose screen the user is looking at. This is the only man-in-the-middle
+ check the user has; do not hide it behind a details view.
+
+Camera permission uses `NSCameraUsageDescription`: "Maestro scans the pairing code shown on your
+computer. The camera is used only for that scan." The scan is `AVCaptureMetadataOutput` with
+`.qr`, no image is written to disk, and no photo library access is requested.
+
+### 2. Bonjour LAN discovery
+
+The desktop advertises `_maestro._tcp` when discovery is enabled. Browse with `NWBrowser`
+(`NWBrowser.Descriptor.bonjourWithTXTRecord(type: "_maestro._tcp", domain: nil)`) so the TXT
+record arrives with the result.
+
+TXT keys, all public by construction:
+
+| Key | Meaning |
+| ------------- | ----------------------------------------------------------- |
+| `version` | Maestro app version |
+| `proto` | A Cappella device protocol version |
+| `fingerprint` | Pairing fingerprint, for the same four-character comparison |
+| `host` | Human name of the desktop |
+
+Client requirements:
+
+- **Discovery never carries a credential.** There is no token and no pairing code in the TXT
+ record. A discovered desktop still needs a code typed or scanned, and the UI must make that
+ obvious rather than implying a discovered machine is a paired one.
+- **Grey out, do not hide, a discovered desktop whose `proto` this build cannot speak**, with the
+ version sentence from [[protocol-conformance]].
+- Requires `NSLocalNetworkUsageDescription` and an `NSBonjourServices` array containing
+ `_maestro._tcp` in `Info.plist`. Without the latter the browse silently returns nothing on iOS
+ 14 and later, which reads as "my Mac is not discoverable" and sends users to the wrong problem.
+- **An empty browse is not an error.** The desktop may have discovery switched off deliberately,
+ or mDNS may be unavailable. Fall through to manual entry with "Cannot see your Mac? Enter its
+ address" rather than a failure dialog.
+
+### 3. Manual host entry
+
+Always available, never buried. Host and port, with the port defaulting to the value the desktop
+shows in its manual-entry hint. This path also covers the reverse-proxy and VPN cases that
+discovery cannot reach.
+
+The server token still has to come from somewhere. On this path the user types the pairing code
+and the desktop's URL including the token segment, which is what the desktop's manual hint
+displays verbatim.
+
+## The pairing exchange
+
+```
+phone desktop
+ |-- {op:'pair-claim', code, name, |
+ | platform:'ios', appVersion} --------->| code checked, consumed, request created
+ |<- {op:'pair-pending', requestId, | human sees an Approve/Deny row
+ | expiresAt} --------------------------|
+ | |
+ |-- {op:'pair-poll', requestId} ---------->| (poll until resolved or expiresAt)
+ |<- {op:'pair-approved', deviceId, token} -| 32-byte token, minted once
+ | or {op:'pair-denied'} |
+ | or {op:'pair-rejected', reason, |
+ | message} |
+```
+
+- `platform` must be the literal `ios`. It is displayed verbatim in the desktop's device list and
+ in the approval row, so a human decides against something recognisable.
+- `name` defaults to `UIDevice.current.name` and must be user-editable before the claim is sent.
+- Poll on a 1 second interval, and stop at `expiresAt` rather than polling forever.
+- `pair-approved` is the **only** time the token exists in the clear. Write it to the Keychain
+ before updating any UI state; a crash between "token received" and "token stored" leaves an
+ approved device that can never authenticate, and there is no recovery path.
+- `pair-rejected` carries a `message` written for a human. Show it verbatim. Do not paraphrase and
+ do not append a generic "Try again".
+
+## Storing the token
+
+```swift
+// Keychain item, one per paired desktop.
+kSecClass: kSecClassGenericPassword
+kSecAttrService: "sh.maestro.acappella.device"
+kSecAttrAccount: deviceId // from pair-approved
+kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
+kSecAttrSynchronizable: false
+kSecValueData: token // UTF-8
+```
+
+Requirements, each with a reason:
+
+- **`kSecAttrAccessibleAfterFirstUnlock`, not `WhenUnlocked`.** The app has to reconnect from the
+ background with the screen locked (see [[background-and-entitlements]]), and `WhenUnlocked`
+ makes the token unreadable in exactly that state.
+- **Never `ThisDeviceOnly` plus iCloud sync.** Synchronizable is off outright: a device token
+ identifies one physical device to one desktop, and syncing it to an iPad produces two devices
+ claiming one identity, which the desktop will treat as a stolen credential.
+- **The server token and host list are stored alongside it**, in the same Keychain item's generic
+ attribute or a second item. They are not secrets of the same weight, but the server token is
+ still a credential and does not belong in `UserDefaults`.
+- **Delete on revocation, delete on `auth-failed`, delete on user "Forget this Mac".** Three
+ paths, one function.
+
+## Authenticating and connecting
+
+```
+ |-- {op:'auth', deviceId, token, protocolVersion} --->|
+ |<- {op:'authenticated', deviceId, protocolVersion, |
+ | iceServers, iceTransportPolicy, audio} ----------|
+ | or {op:'auth-failed', reason, message} |
+ | |
+ |-- {op:'offer', sdp} ------------------------------->| rate limited: 6 per 60s
+ |<- {op:'answer', sdp} -------------------------------|
+ |<=== {op:'ice-candidate'} both directions ==========>|
+```
+
+- **Version is negotiated at `auth`, before the credential is even checked.** An old client is
+ told to update the app; a client from the future is told to update the desktop. Handling is
+ specified in [[protocol-conformance]].
+- **Use the `iceServers` and `iceTransportPolicy` the desktop sent.** Do not carry a hard-coded
+ STUN server in the app. The desktop's settings are the single source of truth, including a
+ `relay`-only policy for a user who does not want their IP addresses exchanged.
+- **Apply the `audio` config** (`fec`, `dtx`, `maxAverageBitrate`, `requestRemoteEchoCancellation`)
+ to the outgoing Opus encoder. See [[audio-session]] for how each maps onto WebRTC.framework.
+- **Five failed `auth` attempts closes the socket.** Do not retry in a tight loop. One attempt per
+ reconnect, with backoff between reconnects.
+- **Offers are limited to 6 per 60 seconds.** That budget is sized for an initial offer plus
+ renegotiation on network changes. A client that rebuilds its peer connection on every ICE
+ hiccup will exhaust it and be rate limited during exactly the handover it was trying to survive.
+
+## Reconnection
+
+The rules, in order of how often they matter:
+
+| Event | Client behaviour |
+| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `RTCPeerConnectionState.disconnected` | **Do nothing for 5 seconds.** This is what an ordinary WiFi-to-LTE handover looks like, and tearing down here is how you break the case you were trying to handle. Show "Reconnecting" in the quality indicator, keep the UI live. |
+| Still `disconnected` after 5s, or `failed` | Restart ICE and send a fresh `offer` on the same authenticated socket. The desktop applies it to the existing peer, so the media leg survives. |
+| Signaling socket closed | Reconnect the socket with backoff, re-`auth`, then re-`offer`. The desktop never inherits an authenticated state across sockets, so `auth` is mandatory every time. |
+| `closed`, or the desktop sent `revoked` | Terminal. Do not reconnect. |
+| `auth-failed` | Terminal. Delete the Keychain item and go to the unpaired state. |
+| App backgrounded without the Push to Talk framework | Expect the connection to die. Reconnect on foreground; see [[background-and-entitlements]]. |
+
+Backoff: 1s, 2s, 4s, 8s, 15s, 30s, then 30s steady, each with up to 30 percent jitter. Reset the
+schedule on any successful `authenticated`. Never reconnect while the app is in the background
+without an active Push to Talk session; a phone in a pocket retrying every 30 seconds all night is
+a battery complaint and a one-star review.
+
+**The floor does not survive a reconnect.** On reconnect the client's mic button starts closed and
+waits for a `floor-state` message. Assuming the floor is still held is how a phone ends up with a
+hot microphone the desktop does not know about.
+
+## The connection quality indicator
+
+Driven by `link-quality` messages on the lossy channel, which both ends emit from throttled
+`getStats()` readings:
+
+```ts
+{ type: 'link-quality', rttMs: number | null, jitterMs: number | null,
+ packetLoss: number, candidateType: 'lan' | 'stun' | 'relay' | 'unknown' }
+```
+
+The indicator has two halves, and both are needed:
+
+**Quality**, from `rttMs`, `jitterMs`, and `packetLoss`:
+
+| Bars | Condition |
+| ---- | ---------------------------------------------- |
+| 3 | rtt < 80 ms, jitter < 20 ms, loss < 2 percent |
+| 2 | rtt < 200 ms, jitter < 50 ms, loss < 5 percent |
+| 1 | connected, anything worse |
+| 0 | not connected |
+
+**Path**, from `candidateType`, shown as a word, not a colour:
+
+| Value | Label | What it means to the user |
+| --------- | ------------------ | -------------------------------------------------------------------------- |
+| `lan` | "Direct" | Host candidate. Same network or an overlay. No infrastructure in the path. |
+| `stun` | "Direct (via NAT)" | Both ends punched through. Media is still peer to peer. |
+| `relay` | "Relayed" | A TURN server is forwarding every packet. Works everywhere, costs latency. |
+| `unknown` | "Connecting" | No candidate pair selected yet. |
+
+Showing the path in words is deliberate. "Why is it slow" and "where does my audio go" are the
+same question for a relayed connection, and a user who can see the word "Relayed" can answer it
+without a support thread.
+
+The client emits its own `link-quality` at most once per 2 seconds. It is throttled at the sender
+because it rides the lossy channel alongside the audio meter, and a chatty stats message competes
+with the thing the user actually hears.
diff --git a/docs/ios-client/interaction-model.md b/docs/ios-client/interaction-model.md
new file mode 100644
index 0000000000..a56f5fe1e8
--- /dev/null
+++ b/docs/ios-client/interaction-model.md
@@ -0,0 +1,229 @@
+---
+type: specification
+title: Interaction Model
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[audio-session]]'
+ - '[[protocol-conformance]]'
+ - '[[../architecture/acappella/wake-and-hotkeys]]'
+---
+
+# Interaction Model
+
+One screen. A wheel at the top, a button in the middle, a transcript you pull up from the bottom.
+Everything else is a sheet reached from a corner.
+
+```
+ +--------------------------------------------------+
+ | (( Direct )) 3 bars Pedram's Mac ... | status strip
+ +--------------------------------------------------+
+ | |
+ | < [ Conductor ] [ backend ] [ docs ] [ api ] > | project wheel
+ | |
+ | |
+ | +----------+ |
+ | | | |
+ | | TALK | | push-to-talk
+ | | | |
+ | +----------+ |
+ | |
+ | "open a tab on the backend" | live partial
+ | |
+ +--------------------------------------------------+
+ | ^ Transcript | drag up for the sheet
+ +--------------------------------------------------+
+```
+
+## The push-to-talk button
+
+The single control that matters. It supports **both** gestures, and which one happened is decided
+by how long the finger was down, against exactly the threshold the desktop uses.
+
+```
+touchDown
+ |
+ +-- send `floor: press` IMMEDIATELY (do not wait to classify)
+ | haptic: .impact(.medium)
+ |
+ +-- start a timer at holdThresholdMs (default 300 ms, clamped 100 to 2000)
+ |
+touchUp before threshold -> TAP: the floor stays open. The button is now latched.
+ A second tap sends `floor: release`.
+touchUp after threshold -> HOLD: send `floor: release` on the lift.
+ haptic: .impact(.light)
+```
+
+Requirements, each earned:
+
+- **Send `press` on touch-down, not on classification.** Waiting 300 ms to find out whether this
+ is a tap or a hold puts 300 ms in front of every utterance, and the desktop's `press` is
+ idempotent, so there is nothing to be gained by waiting. The classification only decides what
+ happens on the lift.
+- **Use the desktop's threshold.** It arrives with the voice settings; do not carry a second
+ constant. `DEFAULT_HOLD_THRESHOLD_MS = 300` in `src/shared/acappella/voice-controls.ts` is the
+ default, `resolveHoldThresholdMs()` is the clamp. A phone that classifies at 250 ms while the
+ desktop hotkey classifies at 300 ms produces two devices that disagree about what a tap is.
+- **Cancel on drag-off.** A finger that slides off the button before lifting sends `floor: release`
+ and shows the cancelled state. A push-to-talk button you cannot back out of is a button people
+ are afraid to press.
+- **The button reflects `floor-state`, not the local gesture.** The gesture is a request. The
+ authoritative state arrives as `{ type: 'floor-state', holder, isSelf, takenOverBy }`, and the
+ button renders that. If another device takes the floor, the button snaps back with
+ `takenOverBy` shown ("Taken by Pedram's iPad"), because a button that lies about holding a
+ microphone is worse than one that flickers.
+- **Never assume the floor across a reconnect.** Start closed, wait for `floor-state`.
+
+### Button states
+
+| State | Appearance | Reached by |
+| ------------- | --------------------------------------- | ------------------------------------------------- |
+| Idle | Outline, "Talk" | Floor closed |
+| Pressed | Filled, growing ring, level meter | `floor-state.isSelf === true` |
+| Latched (tap) | Filled with a lock glyph, "Tap to stop" | Tap classified, floor still open |
+| Held by other | Dimmed, "Pedram's iPad is talking" | `floor-state.holder != null && !isSelf` |
+| Thinking | Pulsing, no meter | `dispatch` seen, no `speak-start` yet |
+| Speaking | Waveform, "Tap to interrupt" | Between `speak-start` and `speak-end` |
+| Interrupted | Outline, brief flash | System audio interruption (see [[audio-session]]) |
+| Disconnected | Greyed, not tappable, reason underneath | Peer or signaling down |
+
+Tapping during **Speaking** sends `interrupt: barge-in`, not a floor press. That is the one
+overloaded gesture in the app and it is worth it: reaching for a separate stop button while
+something is talking at you is exactly when a user cannot aim.
+
+## Haptics
+
+Haptics are the app's only feedback channel when the screen is off or in a pocket, so they are
+specified rather than decorative:
+
+| Moment | Feedback |
+| ------------------------------------ | -------------------------------------------------- |
+| Floor opens (any cause) | `UIImpactFeedbackGenerator(style: .medium)` |
+| Floor closes (any cause) | `UIImpactFeedbackGenerator(style: .light)` |
+| Floor taken by another device | `UINotificationFeedbackGenerator(.warning)` |
+| Wake word fired | `.medium`, identical to a press, because it is one |
+| Stop word fired | `UINotificationFeedbackGenerator(.success)` |
+| Barge-in accepted (`barge-in` event) | `UISelectionFeedbackGenerator` |
+| Session error | `UINotificationFeedbackGenerator(.error)` |
+| Roster changed | Nothing. Background state changes must not buzz. |
+
+Prepare the generators ahead of the gesture (`prepare()` on touch-down) or the first haptic of a
+session arrives late enough to feel like a different event.
+
+## The project wheel
+
+A horizontally scrolling row of agents, driven **entirely** by the `agent-roster` voice event:
+
+```ts
+{ type: 'agent-roster', agents: RosterAgent[] }
+// RosterAgent: { sessionId, name, agentType, cwd, tabs, status?, recentWork? }
+```
+
+Rules:
+
+- **The first item is always Conductor**, which is not an agent. Selecting it sets the scope of
+ the next floor press to `{ kind: 'conductor' }`, which lets the desktop route the utterance
+ itself. Every other item sets `{ kind: 'agent', sessionId }`.
+- **The roster is a snapshot, not a diff.** Replace the list on every `agent-roster` event. Do not
+ merge; the desktop sends a whole roster precisely so the phone cannot accumulate agents that no
+ longer exist.
+- **`status` colours the item** using the same vocabulary as the desktop Left Bar: `idle` green,
+ `busy` yellow, `error` red. Absent means unknown, which renders neutral rather than green.
+- **`recentWork` is the subtitle.** It is the synopsis the desktop's history manager already wrote,
+ and it is what makes the wheel scannable when four agents have similar names.
+- **Selection is a client-side preference only.** It changes the `scope` on the next `floor: press`
+ and nothing else. It does not tell the desktop to focus anything, and it does not survive a
+ roster that no longer contains the selection: fall back to Conductor and say so.
+- **`tab-state` events update the selected agent's tab count** in the item's accessory. A phone
+ that shows tab state is a phone that can answer "did it actually open the tab" without the user
+ walking to the desk.
+
+Scrolling snaps to items. Selecting one is a single tap, with `UISelectionFeedbackGenerator`.
+
+## The live transcript sheet
+
+A `UISheetPresentationController` with detents `[.height(120), .medium(), .large()]`, presented
+non-modally so the talk button stays reachable at the small detent.
+
+Content, in arrival order, built from voice events:
+
+| Event | Row |
+| -------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `partial-transcript` | Replaces the in-flight user row. Lossy channel: rows may skip, never reorder. Key off `seq`. |
+| `final-transcript` | Commits the user row. |
+| `route-decision` | A quiet caption on the user row: "-> backend agent". Includes the confidence when the desktop was unsure. |
+| `dispatch` | A caption: "Opened tab: auth refactor". |
+| `route-correction` | Rewrites the caption in place, with a corrected marker. Do not append a second row; the user said one sentence. |
+| `agent-reply` | Starts the assistant row. |
+| `speak-sentence` | Appends to the assistant row, sentence by sentence, so the text tracks the audio. |
+| `speak-end` | Closes the assistant row. |
+| `barge-in` | Marks the assistant row truncated. Do not delete what was already said. |
+| `stop-word` | A divider. The conversation is over. |
+| `session-error` | An inline error row with the desktop's own message, verbatim. |
+
+Requirements:
+
+- **The desktop's text is the truth.** Never re-render a transcript from local audio, and never
+ "clean up" the desktop's copy.
+- **Order by `seq`, not by arrival.** Two channels means the reliable and lossy streams interleave,
+ and a partial can arrive after the final that superseded it.
+- **The transcript is per-session and not persisted.** A conversation history that lives on the
+ phone is a second brain, and this app does not have one. See [[overview]].
+- **Copy is available, sharing is not.** Long-press copies a row. There is no export, no cloud
+ sync, no attachment sheet.
+
+## The status strip
+
+Three elements, left to right:
+
+1. **The connection quality indicator**, exactly as specified in [[connection-and-pairing]]: bars
+ plus a path word (Direct, Direct via NAT, Relayed, Connecting).
+2. **The desktop name and fingerprint**, tappable to show the paired-device sheet with the four
+ characters to compare.
+3. **A microphone-state pill**, which is the honesty control for the two capture gates in
+ [[audio-session]]. It has three states and each says something different:
+ - "Mic off" - nothing is capturing.
+ - "Listening for wake word" - a local-only tap is running; nothing is transmitted.
+ - "Sending" - the floor is open and audio is going to the desktop.
+
+That third element is not optional. iOS lights an orange indicator whenever any capture is
+running, including the local wake-word tap, and a user who sees it with nothing on screen
+explaining why will assume the worst thing.
+
+## Wake word and stop word
+
+Both run **on the phone**, for the reasons in [[../architecture/acappella/wake-and-hotkeys]] and
+[[overview]]: the wake word cannot be detected remotely without sending the audio it is meant to
+gate, and the stop word must be heard while the desktop is speaking.
+
+Behaviour:
+
+- A wake-word hit is exactly a `floor: press` with the currently selected scope, plus the same
+ haptic a physical press produces. From the desktop's point of view there is no difference, and
+ there must not be one.
+- A stop-word hit sends `interrupt: { kind: 'stop-word' }`. The floor closes and the session ends.
+- **Barge-in and stop word are different things and stay different.** Barge-in means "stop talking,
+ I am still here": speech cancelled, floor kept, microphone open. Stop word means "we are done":
+ speech cancelled, floor released, microphone closed. Every assistant that merged them became one
+ you cannot get rid of.
+- Arming follows the desktop's rule: wake phrases only while the session is cold, stop phrases in
+ every active state, never both. Otherwise a wake phrase spoken mid-answer stacks a second
+ session.
+- Wake word is **off by default** and its toggle sits on the main screen, one tap away. It is the
+ setting most likely to be turned off in a meeting.
+
+## Accessibility
+
+- The talk button is a single large target well past the 44 pt minimum, and it is the only control
+ needed to use the app.
+- VoiceOver: the button announces its state, not its label ("Talk, idle", "Talk, sending",
+ "Talk, held by Pedram's iPad"). The transcript sheet is a standard list and reads normally.
+- Every state distinguished by colour is also distinguished by a word or a glyph. The agent status
+ dots pair with the status word in the item's accessibility label.
+- Dynamic Type is honoured everywhere except the talk button's own label.
+- Reduce Motion removes the pulsing and waveform animations; the states stay distinguishable by
+ fill and label.
diff --git a/docs/ios-client/overview.md b/docs/ios-client/overview.md
new file mode 100644
index 0000000000..3e3d5ee9de
--- /dev/null
+++ b/docs/ios-client/overview.md
@@ -0,0 +1,124 @@
+---
+type: specification
+title: A Cappella iOS Client Overview
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[../architecture/acappella/system-overview]]'
+ - '[[connection-and-pairing]]'
+ - '[[audio-session]]'
+ - '[[interaction-model]]'
+ - '[[background-and-entitlements]]'
+ - '[[app-store-review]]'
+ - '[[protocol-conformance]]'
+ - '[[project-structure]]'
+---
+
+# A Cappella iOS Client Overview
+
+## What this app is
+
+**A remote microphone and speaker for a Maestro desktop.** Nothing more, and the "nothing more"
+is the load-bearing part of the design.
+
+The phone captures audio, sends it to a paired desktop over WebRTC, and plays back what the
+desktop sends home. Every decision about that audio - which speech recogniser transcribes it,
+which agent it is routed to, what tab gets opened, which voice reads the answer - is made on the
+desktop by the code described in [[../architecture/acappella/system-overview]]. The phone
+contributes exactly three things the desktop cannot do for itself: a microphone in another room,
+a speaker in that same room, and a screen to press.
+
+## What this app is not
+
+It is **not a second brain**. It does not:
+
+- run a speech recogniser, a router, or a text-to-speech engine;
+- hold its own conversation state, its own transcript history, or its own agent list;
+- talk to Anthropic, OpenAI, ElevenLabs, or any other provider directly;
+- work at all without a paired desktop that is awake and running Maestro.
+
+If the phone ever needs to make a decision the desktop already makes, that is a bug in this
+specification, not a feature request for the phone. Two implementations of routing will disagree,
+and the one the user is not looking at will be the one that is wrong.
+
+There are exactly **three** exceptions, and each exists because latency or privacy makes a round
+trip to the desktop unacceptable. They are specified in detail in [[protocol-conformance]]:
+
+| Local behaviour | Why it cannot live on the desktop |
+| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Wake word | No audio may leave the device before the wake phrase fires. Detecting it remotely would require sending audio first, which is the thing the rule forbids. |
+| Stop word | It must be heard while the desktop is speaking and a recogniser stream is open. A stop that arrives after the thing it was meant to stop has finished is not a stop. |
+| VAD ducking | Barge-in has to attenuate playback within roughly 20 ms. A round trip to the desktop is 30 to 120 ms before the desktop has even decided anything. |
+
+Everything else the phone does is: press a button, draw what the data channel says, play what the
+audio track carries.
+
+## The shape of a session
+
+```
+ iPhone Maestro desktop
+ | |
+ | user presses talk (or says wake word) |
+ |------ floor: press --------------------> FloorController.press()
+ | | same object the desktop hotkey drives
+ |<----- floor-state: isSelf=true ---------
+ | |
+ |======= Opus audio, phone -> desktop ===> STT, routing, dispatch
+ | |
+ |<----- voice-event: partial-transcript -- (lossy channel)
+ |<----- voice-event: route-decision ------
+ |<----- voice-event: dispatch ------------
+ |<====== Opus audio, desktop -> phone ==== TTS
+ |<----- voice-event: speak-sentence ------
+ | |
+ | user talks over the reply |
+ |------ interrupt: barge-in -------------> session.interrupt()
+ |<----- voice-event: barge-in ------------ authoritative, speech actually cancelled
+```
+
+The phone never invents a `voice-event`. It **requests** (`floor`, `interrupt`) and it
+**renders** (`voice-event`, `floor-state`). Requests are hopeful; events are the truth.
+
+## Why WebRTC and not a WebSocket
+
+Decided in [[../architecture/acappella/decisions/adr-001-webrtc-transport]] and restated here
+because it is the first question any iOS developer will ask:
+
+- **Opus with in-band FEC and DTX** survives the loss profile of a phone on cellular. A raw PCM
+ stream over a WebSocket does not, and a phone leaving WiFi mid-sentence is the normal case, not
+ the edge case.
+- **The audio path is peer to peer** when the network allows it, so a phone on the same LAN as the
+ desktop pays no server hop at all. See the `lan`/`stun`/`relay` distinction in
+ [[connection-and-pairing]].
+- **`AVAudioSession` in `.voiceChat` mode** gives us Apple's hardware voice-processing echo
+ canceller for free, and that mode is designed around a duplex real-time call. See
+ [[audio-session]].
+- **Renegotiation is a first-class path.** A phone walking from WiFi to LTE re-offers on the same
+ authenticated socket and the media leg survives the handover. A WebSocket audio stream would be
+ torn down and rebuilt, mid-sentence.
+
+Signaling still rides Maestro's existing authenticated WebSocket, so there is no second port and
+no second authentication surface.
+
+## Reading order
+
+| Document | Answers |
+| ------------------------------- | ----------------------------------------------------------------------- |
+| [[connection-and-pairing]] | How the phone finds a desktop, earns a token, keeps it, and reconnects. |
+| [[audio-session]] | How to configure `AVAudioSession` and WebRTC so the audio is usable. |
+| [[interaction-model]] | What is on screen and what every gesture does. |
+| [[background-and-entitlements]] | How the app keeps a microphone alive when the screen is off. |
+| [[app-store-review]] | How to get an app that is useless without a paired Mac through review. |
+| [[protocol-conformance]] | Exactly what to send and handle, checkable item by item. |
+| [[project-structure]] | Xcode layout, dependencies, signing, and the non-goal list. |
+
+## The reference client
+
+Before any Swift exists, there is a browser reference client at
+`src/web-desktop/acappella-client/` that speaks this identical protocol. It is not a demo. It is
+the second endpoint the desktop is regression-tested against, and it is the executable answer to
+"what does the wire actually look like" for anyone writing the Swift. When this specification and
+the reference client disagree, the reference client is right and this document has a bug.
diff --git a/docs/ios-client/project-structure.md b/docs/ios-client/project-structure.md
new file mode 100644
index 0000000000..1c268b628f
--- /dev/null
+++ b/docs/ios-client/project-structure.md
@@ -0,0 +1,354 @@
+---
+type: specification
+title: A Cappella iOS Project Structure
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[connection-and-pairing]]'
+ - '[[audio-session]]'
+ - '[[interaction-model]]'
+ - '[[background-and-entitlements]]'
+ - '[[app-store-review]]'
+ - '[[protocol-conformance]]'
+---
+
+# iOS Project Structure
+
+The layout, the dependencies, the signing chain, and the list of things the first Swift effort is
+not allowed to build. Everything here exists to keep one app small enough that one person can
+finish it, and to keep the protocol in it from drifting away from the desktop that defines it.
+
+## Where the code lives
+
+**In this repository, at `ios/`.** Not a second repository.
+
+The argument for a separate repo is that an Xcode project in a TypeScript monorepo is noise, and
+that is true. The argument against it is stronger: the wire protocol is defined by
+`src/shared/acappella/device-protocol.ts`, the reference implementation of the client half is at
+`src/web-desktop/acappella-client/`, and the conformance suite that judges both is at
+`src/__tests__/acappella/conformance/`. A protocol change touches all of them, and it should be
+able to touch the Swift in the same commit and the same review. Two repositories means a change
+lands in one of them first, and the window between the two is exactly where the phone goes silent
+in a way nobody notices until a device is in a hand.
+
+Cost, paid deliberately: macOS runners are expensive, so the iOS job is a separate workflow gated
+on `paths: ['ios/**', 'src/shared/acappella/**']` rather than a fourth leg of the existing
+`test` matrix in `.github/workflows/ci.yml`. The Node CI legs must never wait on Xcode.
+
+## Deployment target and toolchain
+
+| Choice | Value |
+| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Minimum iOS | **16.0** |
+| Devices | iPhone, and iPad unmodified (universal, no iPad-specific layout in v1) |
+| Language | Swift, strict concurrency on, no Objective-C beyond what WebRTC's headers bring in |
+| UI | SwiftUI for everything except the talk button's gesture handling, which is a `UIViewRepresentable` so touch-down can be handled without SwiftUI's gesture-recognition delay |
+| Xcode | Pinned in `ios/.xcode-version` and read by CI. Do not track "latest"; a toolchain bump is a commit like any other. |
+| Dependency manager | Swift Package Manager only. No CocoaPods, no Carthage. |
+
+**iOS 16 is set by one thing and one thing only:** the Push to Talk framework, which is the
+sanctioned way to hold a microphone with the screen off and is therefore the whole background
+story. The reasoning, and the two alternatives that were rejected, are in
+[[background-and-entitlements]]. If that decision is ever revisited, the floor moves with it; no
+other API in this app needs more than iOS 15.
+
+Everything in [[audio-session]], [[connection-and-pairing]], and [[protocol-conformance]] runs on
+iOS 15. The keep-awake path exists partly so the app is usable before the Push to Talk entitlement
+is granted, and it should not be quietly deleted once it is.
+
+## Repository layout
+
+```
+ios/
+├── .xcode-version Toolchain pin, read by CI.
+├── README.md How to build, how to pair against a dev desktop.
+├── ACappella.xcodeproj/ One project. No workspace: SPM needs no .xcworkspace.
+├── ACappella/ The app target. UI and platform glue only.
+│ ├── ACappellaApp.swift @main, scene setup, deep-link and PTT restoration entry.
+│ ├── Info.plist Usage strings and NSBonjourServices; see below.
+│ ├── ACappella.entitlements push-to-talk, aps-environment.
+│ ├── Screens/
+│ │ ├── UnpairedScreen.swift The real screen a reviewer sees first (app-store-review).
+│ │ ├── PairingScreen.swift QR scan, Bonjour list, manual host entry.
+│ │ ├── SessionScreen.swift Status strip, project wheel, talk button, live partial.
+│ │ ├── TranscriptSheet.swift The three-detent sheet.
+│ │ └── KeepAwakeScreen.swift Pure-black fallback background mode.
+│ ├── Components/
+│ │ ├── TalkButton.swift Gesture down/up, states, VoiceOver announcements.
+│ │ ├── ProjectWheel.swift Snap-scrolling roster row.
+│ │ ├── QualityIndicator.swift Bars plus the path word ("Direct", "Relayed").
+│ │ └── MicPill.swift Off / Listening for wake word / Sending. Three states.
+│ ├── Haptics/Haptics.swift One place that owns every feedback generator.
+│ └── Assets.xcassets/
+├── Packages/
+│ ├── ACappellaKit/ The protocol. No UIKit, no AVFoundation, no WebRTC.
+│ │ ├── Package.swift
+│ │ ├── Sources/ACappellaKit/
+│ │ │ ├── Generated/ProtocolConstants.swift GENERATED. Do not hand-edit.
+│ │ │ ├── Signaling/SignalingMessage.swift Codable client and server messages.
+│ │ │ ├── Signaling/SignalingSocket.swift Protocol (the Swift kind). Injected.
+│ │ │ ├── Device/DeviceMessage.swift The ten data-channel messages.
+│ │ │ ├── Device/DeviceCodec.swift encode/decode, the `v` stamp, seq gaps.
+│ │ │ ├── Device/SessionEvent.swift The twenty voice events.
+│ │ │ ├── Session/ClientState.swift Phase, floor view, roster, transcript.
+│ │ │ ├── Session/ACappellaClient.swift The state machine. Everything injected.
+│ │ │ ├── Session/Reconnection.swift Backoff, terminal versus retryable.
+│ │ │ └── Ports/ PeerPort, MicrophonePort, Clock, TokenStore.
+│ │ └── Tests/ACappellaKitTests/
+│ │ ├── Fixtures/ GENERATED golden frames. See below.
+│ │ └── Conformance/ Test names carry their C-nn.
+│ ├── ACappellaAudio/ The adapters. WebRTC and AVFoundation live only here.
+│ │ ├── Sources/ACappellaAudio/
+│ │ │ ├── WebRTCPeer.swift PeerPort over RTCPeerConnection.
+│ │ │ ├── AudioSessionController.swift RTCAudioSession, category, mode, manual audio.
+│ │ │ ├── RouteObserver.swift routeChange and interruption handling.
+│ │ │ ├── OpusPreferences.swift fmtp munging from RemoteAudioConfig.
+│ │ │ ├── LevelMeter.swift audio-level source, floor-gated.
+│ │ │ └── Ducking.swift Local VAD and the 20 ms attenuation.
+│ │ └── Tests/ACappellaAudioTests/
+│ ├── ACappellaTransport/ The other adapters: socket, discovery, Keychain.
+│ │ ├── Sources/ACappellaTransport/
+│ │ │ ├── URLSessionSignalingSocket.swift
+│ │ │ ├── BonjourBrowser.swift NWBrowser over _maestro._tcp.
+│ │ │ ├── PairingPayload.swift QR JSON parse and validation.
+│ │ │ └── KeychainPairingStore.swift TokenStore over kSecClassGenericPassword.
+│ │ └── Tests/ACappellaTransportTests/
+│ └── ACappellaWake/ The one on-device model. Isolated so it can be removed.
+│ ├── Sources/ACappellaWake/
+│ │ ├── WakeWordDetector.swift
+│ │ └── StopWordDetector.swift
+│ └── Tests/ACappellaWakeTests/
+└── Fastlane/ (optional) Only if TestFlight uploads become manual toil.
+```
+
+Three rules the layout is enforcing, each of which is a lesson from the reference client:
+
+- **`ACappellaKit` imports nothing.** Not UIKit, not AVFoundation, not WebRTC, not Network. It is
+ the direct analogue of `client.ts`, which has no DOM in it, and it exists for the same reason:
+ the protocol has to be testable on a machine with no microphone, no camera, and no phone. If a
+ Swift file in `ACappellaKit` needs `import AVFoundation`, the thing it is doing belongs in
+ `ACappellaAudio` behind a port.
+- **Ports are protocols, and the app target owns the wiring.** `ACappellaClient` receives a
+ `PeerPort`, a `MicrophonePort`, a `Clock`, and a `TokenStore`. In the app they are the WebRTC,
+ AVFoundation, system, and Keychain implementations. In tests they are fakes. Nothing in
+ `ACappellaKit` ever constructs one.
+- **`ACappellaWake` is a separate package specifically so it can be cut.** It is the only thing in
+ the app with a model file in it, it is the only thing that opens a microphone the desktop does
+ not know about, and it is the most likely thing to be replaced. Isolating it means the answer to
+ "what happens if the wake word ships later" is "remove one dependency", not "unpick it from the
+ session code".
+
+## Dependencies
+
+| Dependency | How | Why, and what happens without it |
+| -------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **WebRTC** | Binary XCFramework via SPM, exact version pinned, checksum recorded | The entire media path. There is no first-party Apple or Google SPM package: Google's `GoogleWebRTC` CocoaPod has been unmaintained for years, so the practical choice is a maintained community XCFramework build. |
+| **Push to Talk** | System framework (`import PushToTalk`) | Background microphone. Entitlement-gated; see the signing section. |
+| **AVFoundation** | System | Audio session, and `AVCaptureMetadataOutput` for the QR scan. |
+| **Network** | System | `NWBrowser` for `_maestro._tcp` discovery. |
+| **Security** | System | Keychain. See [[connection-and-pairing]] for the exact item attributes. |
+| **Wake-word engine** | Decided in the Swift effort, isolated in `ACappellaWake` | Keyword spotting only. Whatever is chosen must run offline, be small, and never transmit. This is the one place a licence or a per-device fee can enter the project, so decide it with eyes open. |
+
+**Explicit non-dependencies.** Each of these is a package someone will reach for, and each has a
+reason not to be here:
+
+- **No QR-scanning library.** `AVCaptureMetadataOutput` with `.qr` is about forty lines and reads
+ the payload in [[connection-and-pairing]] directly. A scanning SDK is a camera permission story
+ and a privacy manifest for something the OS already does.
+- **No networking library.** Signaling is one WebSocket. `URLSessionWebSocketTask` is enough, and
+ it is the only thing in the app that has to survive an OS behaviour change on backgrounding.
+- **No analytics, crash, or attribution SDK in v1.** A microphone app whose privacy label says
+ "no data collected" must be able to keep saying it. If crash reporting is added later, it goes
+ through Apple's own organiser first, and the privacy label changes with it, deliberately.
+- **No dependency injection, logging, or reactive framework.** Four packages and a state machine
+ do not need one, and the reference client proves the protocol fits in one file with no framework
+ at all.
+
+**Third-party binary obligations.** Before the first submission, check the WebRTC XCFramework
+against Apple's current rules for third-party SDKs: whether it appears on the commonly-used-SDK
+list (which forces a privacy manifest and a signature), what required-reason APIs it touches, and
+whether its own bundled dependencies are separately listed. Do not assume it is exempt because it
+is a media library. If the shipped binary lacks what is required, the escape hatch is building
+libwebrtc from source with a generated privacy manifest, which is a week of work and needs to be
+discovered before the submission, not during it.
+
+## Generated protocol constants, and generated fixtures
+
+Two files in the tree above are marked GENERATED. Both exist because a hand-copied constant is a
+protocol split into two disagreeing halves, and the disagreement is silent on the wire.
+
+**`Generated/ProtocolConstants.swift`** is emitted by `scripts/generate-ios-protocol-constants.mjs`
+from the TypeScript that already owns each value:
+
+| Swift constant | Source |
+| ------------------------ | ---------------------------------------------------------------------------- |
+| `deviceProtocolVersion` | `DEVICE_PROTOCOL_VERSION` in `src/shared/acappella/device-protocol.ts` |
+| `minSupportedVersion` | `MIN_SUPPORTED_DEVICE_PROTOCOL_VERSION`, same file |
+| `reliableChannelLabel` | `RELIABLE_CHANNEL_LABEL` (`acappella-state`) |
+| `unreliableChannelLabel` | `UNRELIABLE_CHANNEL_LABEL` (`acappella-live`) |
+| channel inits | `RELIABLE_CHANNEL_INIT`, `UNRELIABLE_CHANNEL_INIT` |
+| `defaultHoldThresholdMs` | `DEFAULT_HOLD_THRESHOLD_MS` and its 100 to 2000 clamp in `voice-controls.ts` |
+| `defaultWakePhrase` | `DEFAULT_WAKE_PHRASE`, `DEFAULT_STOP_PHRASE`, `FALLBACK_STOP_PHRASE` |
+| Bonjour service type | `_maestro._tcp`, from `src/main/acappella/pairing/discovery.ts` |
+
+The generator runs in the iOS CI job and the job fails if the output differs from what is
+committed. A drift is then a red build on the commit that caused it rather than a phone that
+cannot open a data channel.
+
+Note the difference between a default and a live value. `defaultHoldThresholdMs` is what the client
+uses before it has heard from a desktop. The desktop's own value arrives in the `authenticated`
+message and wins; see [[interaction-model]]. The constant exists so the two are the same number on
+the first press, not so the client can decide the threshold.
+
+**`Tests/ACappellaKitTests/Fixtures/`** holds golden frames exported by
+`scripts/export-acappella-fixtures.mjs` from the conformance suite: one JSON file per message type,
+byte-for-byte what the desktop encodes and what it accepts. The Swift tests decode them and encode
+back. This is what catches an optional field that Swift's `Codable` silently drops, which is the
+failure that looks exactly like a working client that does nothing.
+
+## Mapping to the reference client
+
+A Swift developer starting cold should read the reference client first and this table second. Every
+row is a file that already exists and already runs.
+
+| Swift | Reference client |
+| -------------------------------------- | -------------------------------------------- |
+| `ACappellaKit` | `src/web-desktop/acappella-client/client.ts` |
+| `ACappellaAudio`, `ACappellaTransport` | `src/web-desktop/acappella-client/main.ts` |
+| `Screens/`, `Components/` | `src/web-desktop/acappella-client/ui.ts` |
+| `ACappellaKitTests/Conformance/` | `src/__tests__/acappella/conformance/` |
+| `ProtocolConstants.swift` | direct imports from `src/shared/acappella/` |
+
+Where the Swift and the reference client disagree, one of them is wrong, and
+[[protocol-conformance]] decides which. There is exactly one sanctioned divergence, documented in
+both places: the browser cannot keep the audio unit cold the way `RTCAudioSession.useManualAudio`
+does, so it acquires and stops the track around the floor instead.
+
+## Build configurations
+
+| Configuration | Purpose | Push to Talk | Distribution |
+| ------------- | -------------------------------- | -------------------------------------------------- | -------------- |
+| `Debug` | Simulator and device development | Off. `UIBackgroundModes: audio` allowed here ONLY. | Never archived |
+| `Beta` | TestFlight | On once the entitlement is granted | TestFlight |
+| `Release` | App Store | On | App Store |
+
+The `Debug`-only background `audio` mode is the compromise in [[background-and-entitlements]]: it
+lets protocol work happen with the screen off before the entitlement exists. It must be impossible
+to archive. Put it in a `Debug`-only `Info.plist` fragment rather than behind an `#if DEBUG` in
+Swift, because a plist key cannot be conditionally compiled and a reviewer reads the plist.
+
+## Signing and capabilities
+
+| Item | Value |
+| ----------------- | --------------------------------------------------------------------------------------------- |
+| Bundle identifier | `sh.maestro.acappella` (matches the Keychain `kSecAttrService` in [[connection-and-pairing]]) |
+| Capabilities | Push to Talk, Push Notifications, Background Modes (`push-to-talk` only) |
+| Entitlements file | `ACappella/ACappella.entitlements` |
+| Signing | Automatic for `Debug`, manual with a committed profile name for `Beta` and `Release` |
+
+### The Push to Talk entitlement, and the ordering problem it creates
+
+`com.apple.developer.push-to-talk` is a **restricted entitlement**. It is requested through Apple's
+form, it is reviewed by a human, and until it is granted the identifier cannot carry it, which
+means the provisioning profile cannot carry it, which means a build that declares it will not sign.
+
+That is a schedule constraint, not a paperwork detail:
+
+1. **Request the entitlement on day one of the Swift effort.** It is the longest-lead item in the
+ project and everything else can be built while it is pending.
+2. Build against the keep-awake path in the meantime. It needs no entitlement and no review
+ conversation, and it has to exist in the shipped app anyway.
+3. When the grant arrives, add the capability to the identifier, regenerate the `Beta` and
+ `Release` profiles, and flip the configuration. Nothing else changes: the PTT channel manager
+ drives the same `floor` messages the button does.
+
+The request text should say what the app is in one sentence and then answer the question the
+reviewer is actually asking, which is whether this is a walkie-talkie or a background recorder. The
+answer is in [[app-store-review]] and the entitlement request should reuse its wording rather than
+inventing a second description of the same app.
+
+Push Notifications is required because the Push to Talk framework delivers channel activity over
+PushKit. `aps-environment` is `development` in `Debug` and `production` in `Beta` and `Release`.
+The app sends no other push and must not acquire a notification permission prompt it does not need.
+
+### TestFlight
+
+- **Internal testers first**, which needs no beta review and is where the pairing flow gets its
+ real-network testing. A simulator cannot scan a QR code from a real screen and cannot join a
+ Bluetooth headset, and both of those are where this app breaks.
+- **External testing needs beta app review**, and that review hits the same wall as the App Store
+ review does: the app does nothing without a paired Mac. Have the demo desktop and the demo video
+ from [[app-store-review]] ready before the first external build, not after it is rejected.
+- **Build numbers are monotonic and generated**, not typed. Marketing version tracks the desktop's
+ minor version so a support conversation can compare them.
+- **Export compliance is a real question, not a checkbox.** The app encrypts: DTLS-SRTP inside
+ WebRTC and TLS on the signaling socket. Whether that qualifies for the standard-algorithms
+ exemption depends on the third-party binary as much as on our own code. Decide it once, in
+ writing, with someone qualified to answer, and record the answer next to the `Info.plist` key
+ rather than re-guessing at each submission.
+
+## Continuous integration
+
+One workflow, `.github/workflows/ios.yml`, on a macOS runner, gated on `ios/**` and
+`src/shared/acappella/**`:
+
+1. `node scripts/generate-ios-protocol-constants.mjs --check` and the fixture equivalent. Fail on
+ any diff against what is committed.
+2. Build `ACappella` for the simulator.
+3. `swift test` on `ACappellaKit`, `ACappellaTransport`, and `ACappellaWake`. These need no
+ simulator and no device, which is the payoff of the import rules above.
+4. `xcodebuild test` for `ACappellaAudio`, which does need a simulator.
+
+The existing Node matrix in `.github/workflows/ci.yml` stays exactly as it is. An iOS build must
+never be able to make `test (ubuntu-latest)` or `test (windows-latest)` slower or redder.
+
+## Non-goals
+
+The first Swift effort ships a remote microphone and speaker. These are the things it will be
+tempted to build, and each one is a way to turn a two-month project into a year.
+
+**Not building, ever, because the desktop already decides it:**
+
+- **No on-device speech recognition, routing, or text-to-speech.** No `SFSpeechRecognizer`, no
+ `AVSpeechSynthesizer`, no LLM, no summariser. `NSSpeechRecognitionUsageDescription` must not
+ appear in `Info.plist`. The single exception is the wake and stop keyword spotter in
+ `ACappellaWake`, which classifies one phrase and transmits nothing; it is not a recogniser and
+ must not be allowed to become one.
+- **No agent management.** No creating, deleting, renaming, or reconfiguring agents. No running
+ commands. No file browsing. The project wheel is a picker over a roster the desktop sent, and
+ selecting an item changes the scope of the next press and nothing else.
+- **No settings that duplicate desktop settings.** Provider choice, model downloads, TTS voice and
+ rate, hold threshold, wake phrase text, idle timeout: all of these live on the desktop and arrive
+ over the wire. The phone's settings screen holds exactly what is about this phone: which desktop
+ is paired, whether the wake word is armed, whether keep-awake is on, and the output route. If a
+ setting can be answered by "what did the desktop say", it is not a phone setting.
+- **No local conversation history.** The transcript sheet renders the current session from the
+ events that arrived and is gone when the session ends. History lives on the desktop, which is
+ where it can be searched, and where it is already backed up.
+
+**Not in v1, deferred deliberately:**
+
+- Multiple simultaneous desktops. Store several pairings, connect to one at a time.
+- Apple Watch, macOS, visionOS, and CarPlay targets. CarPlay in particular is called out as a
+ do-not-ship in [[audio-session]] for reasons that outlast v1.
+- Siri and App Intents. The wake word covers the same ground without an entitlement conversation.
+- An iPad-specific layout, widgets, Live Activities, and Focus filters.
+- Any account, sign-in, or server of our own. The pairing token is the only credential the app has
+ and there is nothing to log into.
+
+If a feature request cannot be phrased as "the microphone, the speaker, or the button", the answer
+is that it belongs on the desktop. That is the whole thesis of [[overview]], and this list is what
+it looks like when it is enforced.
+
+## Related
+
+- [[overview]] for what the app is and, more usefully, what it is not.
+- [[protocol-conformance]] for the wire behaviour every one of these packages exists to implement.
+- [[background-and-entitlements]] for why the deployment target is 16.0 and why the entitlement is
+ the critical-path item.
+- [[app-store-review]] for the submission material the TestFlight external build needs first.
+- `src/web-desktop/acappella-client/README.md` for the endpoint this project is a port of.
diff --git a/docs/ios-client/protocol-conformance.md b/docs/ios-client/protocol-conformance.md
new file mode 100644
index 0000000000..60026a33ae
--- /dev/null
+++ b/docs/ios-client/protocol-conformance.md
@@ -0,0 +1,565 @@
+---
+type: specification
+title: Protocol Conformance
+created: 2026-08-14
+tags:
+ - ios
+ - voice
+ - acappella
+related:
+ - '[[overview]]'
+ - '[[connection-and-pairing]]'
+ - '[[interaction-model]]'
+ - '[[audio-session]]'
+ - '[[background-and-entitlements]]'
+ - '[[project-structure]]'
+ - '[[../architecture/acappella/voice-session-protocol]]'
+ - '[[../architecture/acappella/transport-and-pairing]]'
+---
+
+# Protocol Conformance
+
+This is the contract. [[connection-and-pairing]] says how a phone finds a desktop and
+[[interaction-model]] says what the screen does; this file says exactly what goes on the wire, in
+what order, and what a client must do with every frame it receives.
+
+Everything here was read out of the implementation rather than designed alongside it. The source of
+truth, in the order a message meets it:
+
+| Layer | File |
+| -------------------------- | ----------------------------------------------------------------- |
+| WebSocket envelope | `src/main/web-server/handlers/messageHandlers/acappellaSignal.ts` |
+| Signaling messages | `src/shared/acappella/signaling-protocol.ts` |
+| Signaling behaviour | `src/main/acappella/transport/signaling.ts` |
+| Data-channel messages | `src/shared/acappella/device-protocol.ts` |
+| Session events | `src/shared/acappella/protocol.ts` |
+| Peer and channel behaviour | `src/renderer/acappella-audio/peer-connection.ts` |
+| SDP tuning and link stats | `src/shared/acappella/peer-tuning.ts` |
+| Floor rules | `src/main/acappella/transport/remote-session.ts` |
+
+**If this document and those files disagree, the files win and this document is a bug.** The
+conformance suite at `src/__tests__/acappella/conformance/` exists to make that disagreement fail in
+CI rather than at App Store review, and every checklist item at the end of this file carries an ID
+the suite can name.
+
+**There is also a working client.** `src/web-desktop/acappella-client/` is a browser implementation
+of this entire document: it pairs, authenticates, offers, opens both data channels, holds the floor,
+and speaks every message defined below, in about 1,700 lines of framework-free TypeScript. Where this
+document says what a client must do, that client is the runnable version, and where a comment there
+cites a `C-nn` it is implementing the checklist item of that name. Read it alongside this file; the
+desktop is served it at `/$TOKEN/acappella`.
+
+## The two layers
+
+```
+ phone desktop
+ | |
+ | 1. WebSocket wss://host:port/$TOKEN/ws |
+ | {type:'acappella_signal', payload:{op:...}} | pairing, auth, SDP, ICE
+ |<-------------------------------------------------------->|
+ | |
+ | 2. WebRTC peer connection |
+ | audio track (Opus) | the microphone
+ | 'acappella-state' reliable, ordered | roster, floor, events
+ | 'acappella-live' unreliable, unordered | meter, partials, gestures
+ |<========================================================>|
+```
+
+Layer 1 exists to build layer 2 and then to tear it down. Once the peer is up, nothing that matters
+to the user travels over the WebSocket: a phone whose socket dropped but whose peer is healthy keeps
+working, and that is deliberate.
+
+---
+
+## 1. Signaling messages
+
+### Envelope
+
+Every signaling frame is a WebSocket text frame carrying the app's ordinary client-message shape:
+
+```json
+{ "type": "acappella_signal", "payload": { "op": "..." } }
+```
+
+`payload` is one `SignalingClientMessage` outbound, one `SignalingServerMessage` inbound. There is no
+second port, no second token, and no separate handshake: the URL's `$TOKEN` is the server token from
+the QR payload, and clearing it is what gets a frame looked at in the first place.
+
+A client that sends `acappella_signal` while A Cappella is switched off on the desktop receives
+`{op:'error', code:'not-authenticated', message:'A Cappella is not running on this desktop. Turn it
+on in Encore Features.'}`. Show that sentence. It is the difference between a feature that is off and
+a network that ate the frame, and only one of those is worth retrying.
+
+### Client to desktop
+
+| `op` | Payload | Precondition |
+| --------------- | ---------------------------------------------------------------------------------- | ------------------------- |
+| `pair-claim` | `{ code: string, name: string, platform: string, appVersion?: string }` | Unpaired only |
+| `pair-poll` | `{ requestId: string }` | After `pair-pending` |
+| `auth` | `{ deviceId: string, token: string, protocolVersion: number }` | Have a stored token |
+| `offer` | `{ sdp: { type: 'offer', sdp: string } }` | **After `authenticated`** |
+| `ice-candidate` | `{ candidate: { candidate: string, sdpMid?, sdpMLineIndex?, usernameFragment? } }` | **After `authenticated`** |
+| `bye` | `{}` | Any time |
+
+What the desktop's parser actually does with a malformed frame, because a client that relies on
+coercion will break the day the parser tightens:
+
+- `pair-claim` without a string `code` is dropped as `malformed`. A non-string `name` or `platform`
+ is **silently coerced to the empty string**, which produces a nameless row in the approval sheet.
+ Send both, always, and send `platform` as the literal `ios`.
+- `auth` without a string `deviceId` **and** a string `token` is dropped as `malformed`.
+- `auth` whose `protocolVersion` is not a number is treated as version 0, which fails negotiation
+ with `client-too-old`. Absent is not "unversioned"; it is "too old".
+- `offer` is accepted only when `sdp` is an object with a string `sdp` field. The `type` is forced to
+ `offer` regardless of what was sent, so a client cannot smuggle an answer through the offer path.
+- `ice-candidate` requires a string `candidate`. The three optional fields default to `null` when
+ they are the wrong type, which is why an end-of-candidates marker must be sent as an empty-string
+ `candidate` rather than as a null one.
+- Anything else, including an unknown `op`, produces one
+ `{op:'error', code:'malformed', message:'Unrecognised signaling message.'}` and no state change.
+
+### Desktop to client
+
+| `op` | Payload | Meaning |
+| --------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------- |
+| `pair-pending` | `{ requestId: string, expiresAt: number }` | A human is being asked |
+| `pair-approved` | `{ deviceId: string, token: string }` | The only time the token exists in the clear |
+| `pair-denied` | `{}` | A human said no |
+| `pair-rejected` | `{ reason: string, message: string }` | Bad, used, busy, or expired code |
+| `authenticated` | `{ deviceId, protocolVersion, iceServers, iceTransportPolicy: 'all' \| 'relay', audio }` | Signaling session open |
+| `auth-failed` | `{ reason: 'unauthorized', message: string }` | Unknown, wrong, or revoked |
+| `answer` | `{ sdp: { type: 'answer', sdp: string } }` | Apply as the remote description |
+| `ice-candidate` | `{ candidate: IceCandidatePayload }` | Trickled from the desktop |
+| `closed` | `{ reason: string }` | This signaling session is over |
+| `error` | `{ code: SignalingErrorCode, message: string }` | See below |
+
+`audio` is a `RemoteAudioConfig`:
+`{ fec: boolean, dtx: boolean, maxAverageBitrate: number, requestRemoteEchoCancellation: boolean }`,
+defaulting to `{ true, true, 24000, true }`. Apply it to the outgoing encoder; see [[audio-session]].
+
+Three shapes that catch every first implementation:
+
+1. **`pair-pending` in response to a `pair-poll` carries `expiresAt: 0`.** Only the first
+ `pair-pending`, the one answering `pair-claim`, carries the real deadline. Keep that value; do not
+ overwrite it from a poll response, or the pairing screen's countdown jumps to 1970.
+2. **`auth-failed` is deliberately vague.** Unknown device, wrong token, and revoked device all
+ produce `reason: 'unauthorized'` with one sentence. Do not try to tell them apart; the desktop is
+ refusing to be an enumeration oracle. Every one of them is terminal for the stored token.
+3. **A version failure is an `error`, not an `auth-failed`.** See section 3.
+
+### Error codes
+
+| `code` | When | Client behaviour |
+| ------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
+| `not-authenticated` | `offer` or `ice-candidate` before `authenticated` | Bug. Fix the ordering; never retry blind. |
+| `rate-limited` | 7th offer in 60 s, or a 6th `auth` attempt on one socket | Stop. Back off. For auth, open a **new socket** before retrying. |
+| `protocol-version` | `auth` with an unusable `protocolVersion` | Terminal. Show the message verbatim. See section 3. |
+| `malformed` | Unparseable frame | Bug. Log it with the frame; do not retry the same bytes. |
+| `peer-failed` | The desktop's peer connection failed | Tear down the peer, restart ICE, re-`offer` under the backoff. |
+
+### Ordering and limits
+
+- **`offer` and `ice-candidate` are refused until `auth` has succeeded on THIS socket.** An
+ authenticated state is never inherited across sockets. Reconnect means `auth` again, every time.
+- **Offers: 6 per 60 seconds, sliding window.** Sized for an initial offer plus a renegotiation per
+ network change. A client that rebuilds its peer on every ICE hiccup exhausts the budget during
+ exactly the handover it was trying to survive.
+- **Failed auths: 5 per socket.** The 6th and every one after it answers `rate-limited` on that
+ socket forever. The recovery is a new socket after backoff, not a tighter loop.
+- **One live signaling session per device.** A second successful `auth` for the same `deviceId`
+ displaces the first, and the displaced socket receives
+ `{op:'closed', reason:'this device connected again from somewhere else'}`. That is a normal message
+ on a phone that changed networks, not an error to show.
+- **`bye` before a deliberate teardown.** It closes the peer cleanly instead of leaving the desktop to
+ discover the loss from ICE, which takes seconds the user can hear.
+
+---
+
+## 2. Data-channel messages
+
+### The channels
+
+The **client is the offerer**, so the client creates both data channels. The desktop binds them in
+`ondatachannel` by label and **closes any channel whose label it does not recognise**, so a typo is a
+silently dead channel rather than a warning.
+
+| Label | `RTCDataChannelInit` | Carries |
+| ----------------- | --------------------------------------- | ------------------------------------- |
+| `acappella-state` | `{ ordered: true }` | State the far end must not miss |
+| `acappella-live` | `{ ordered: false, maxRetransmits: 0 }` | Traffic superseded within about 50 ms |
+
+Create both before the offer, so the negotiated SDP includes the SCTP association from the start.
+
+### Encoding
+
+One JSON object per message, sent as a string. Every frame carries `v`, the negotiated protocol
+version, stamped by the sender.
+
+```json
+{ "type": "floor", "action": "press", "scope": { "kind": "conductor" }, "v": 1 }
+```
+
+The desktop's decoder returns "that message did not exist" rather than throwing, for anything that
+fails any of these:
+
+- not a string, or not parseable JSON, or not a non-array object;
+- `type` is not a string, or is not one of the ten known types;
+- **`v` is not a number.** A frame without `v` is dropped in silence. This is the single most common
+ way a first client appears to be connected and does nothing at all.
+- `voice-event` without an `event` object carrying a string `type`;
+- `floor` whose `action` is neither `press` nor `release`.
+
+Malformed frames are dropped individually. They never close the channel, and there is no negative
+acknowledgement, so a client cannot detect this by waiting for a complaint.
+
+### Client to desktop
+
+Exactly five types. The desktop's `DEVICE_ORIGINATED_MESSAGES` list is
+`['hello', 'floor', 'interrupt', 'audio-level', 'link-quality']`; anything else arriving from a device
+is not the client's to send.
+
+| Type | Payload | Channel | When |
+| -------------- | --------------------------------------------------------- | ------- | ------------------------------------------------ |
+| `hello` | `{ identity: { deviceId, name, platform, appVersion? } }` | `state` | First frame after the state channel opens |
+| `floor` | `{ action: 'press' \| 'release', scope?: VoiceScope }` | `live` | Push-to-talk, and a wake-word hit |
+| `interrupt` | `{ kind: 'barge-in' \| 'stop-word' }` | `live` | Talking over the reply, or the stop word |
+| `audio-level` | `{ level: number, speech: boolean }` | `live` | ~20/s **while the floor is open, and only then** |
+| `link-quality` | `{ rttMs, jitterMs, packetLoss, candidateType }` | `live` | Every ~2 s from a throttled `getStats()` |
+
+`VoiceScope` is `{ kind: 'conductor' }` or `{ kind: 'agent', sessionId: string }`, where `sessionId`
+is an **agent** id from the roster, never a voice session id. Omitting `scope` means conductor.
+
+**Push-to-talk deliberately rides the lossy channel.** A dropped release cannot leave a hot
+microphone: the desktop's floor has an idle timeout, the next press is idempotent, and the
+authoritative `floor-state` comes back either way.
+
+**A client must not send a `voice-event`.** The Phase 01 protocol marks `wake`, `final-transcript`,
+`barge-in`, and `stop-word` as client-originable, but not over this transport: the device channel
+expresses them as `floor` and `interrupt`, and a wrapped `voice-event` from a device is dropped by
+the coordinator's switch. There is no error, so this fails as silence.
+
+### Desktop to client
+
+| Type | Payload | Channel |
+| ------------------ | -------------------------------------------------------------------- | --------- |
+| `welcome` | `{ version: number, appVersion: string, sessionId: string \| null }` | `state` |
+| `version-rejected` | `{ reason, message, desktopVersion, minimumVersion }` | `state` |
+| `voice-event` | `{ event: VoiceEvent }` | see below |
+| `floor-state` | `{ holder: string \| null, isSelf: boolean, takenOverBy?: string }` | `state` |
+| `revoked` | `{ message: string }` | `state` |
+| `link-quality` | `{ rttMs, jitterMs, packetLoss, candidateType }` | `live` |
+
+- `floor-state.holder` is a device id, the literal `'local'` for the desktop's own microphone, or
+ `null` when nobody holds the floor. `isSelf` saves the client an id comparison; trust it.
+- `takenOverBy` is a **display name**, already resolved, and is set only on the message sent to the
+ device that just lost the floor. Show it as written. It is also **momentary**: it rides its own
+ frame, and the ordinary `floor-state` broadcast that follows a takeover carries no name at all. So
+ react to the frame (a banner, a haptic) rather than rendering the field out of stored state, or the
+ notice will erase itself a few milliseconds after it appears.
+- `revoked` is the last frame before teardown, sent for revocation and for any deliberate close. Its
+ `message` is the reason and is written for a human.
+
+### Channel routing
+
+The split is total and stated in one place, `deviceChannelForMessage()`. A client that guesses will
+eventually put a `revoked` on the lossy channel, which is a device that keeps its microphone.
+
+| Message | Channel |
+| ---------------------------------------------------------------- | ------------ |
+| `hello`, `welcome`, `version-rejected`, `floor-state`, `revoked` | `reliable` |
+| `floor`, `interrupt`, `audio-level`, `link-quality` | `unreliable` |
+| `voice-event` with `audio-level` or `partial-transcript` | `unreliable` |
+| `voice-event`, every other event type | `reliable` |
+
+The desktop falls back to the reliable channel when a lossy message needs to go out before
+`acappella-live` is open. A client should do the same for the first `hello`-adjacent traffic and stop
+once both channels report open.
+
+### The session-event catalogue
+
+Every `voice-event` carries `sessionId` (the **voice** session, not an agent), `seq` (monotonic from
+1 per session), and `ts` (epoch ms). All twenty types, and what a conforming client does with each:
+
+| Event | Client duty |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `wake` | Show arming. Carries `source` and `origin`; a remote origin naming another device is not this phone. |
+| `listen-start` | The floor is genuinely open. Start the meter. `sttProviderId` names the engine; show it on demand. |
+| `listen-stop` | The floor closed. `reason` is `endpoint`, `stopped`, `interrupted`, or `error`. Clears a remote holder. |
+| `partial-transcript` | Replace the in-flight user row. Lossy: may skip, never reorder. `text` is the full hypothesis, not a delta. |
+| `final-transcript` | Commit the user row. |
+| `route-decision` | Caption the user row with the target and the latency. |
+| `dispatch` | Caption with the agent, tab, and whether the prompt was sent. This is the "it actually landed" signal. |
+| `route-correction` | **Rewrite the existing caption in place.** Do not append a row: the user said one sentence. |
+| `agent-reply` | Start the assistant row. Show `text`; `spokenText` is what is being said aloud. |
+| `speak-start` | Speech begins. `sentenceCount` is a **lower bound while `streaming` is true**, so indices will run past it. Never clamp. |
+| `speak-sentence` | Append. Drop any sentence whose `utteranceId` is not the current run. |
+| `speak-end` | `complete`, `cancelled`, or `error`. Return the button to idle or latched. |
+| `barge-in` | The authoritative confirmation of an interrupt. Selection haptic. The floor is **kept**. |
+| `stop-word` | The session ended. Floor released, mic closed, success haptic. |
+| `session-error` | Show `message` verbatim. `code` decides the affordance; `recoverable` decides whether to offer a retry. |
+| `audio-level` | Meter, when the desktop's microphone is the open one. |
+| `mic-state` | The **desktop's** microphone, not the phone's. Never drive the local mic pill from this. |
+| `provider-state` | Powers the "where does my audio go" sheet. Show `egressStatement` verbatim and honour `audioLeavesMachine`. |
+| `tab-state` | Update the selected agent's tab accessory. |
+| `agent-roster` | **Replace** the project wheel. A snapshot, never a diff. |
+
+`session-error` codes, all of which must render a sentence rather than a spinner:
+`provider-unavailable`, `provider-auth-failed`, `provider-quota-exceeded`, `provider-network-error`,
+`no-agent-matched`, `dispatch-failed`, `audio-capture-failed`.
+
+**Sequence handling.** `seq` is contiguous per voice session on the reliable channel; a gap there
+means frames were lost and the client should treat its transcript as suspect rather than silently
+stitching. Gaps in `audio-level` and `partial-transcript` are expected and carry no meaning: they are
+the two events that ride the lossy channel by design. A `sessionId` change resets `seq` to 1, which
+is not a gap.
+
+**Unknown is not fatal.** A client must ignore an unrecognised `voice-event.type` and an
+unrecognised field, and must keep processing the stream. That rule is what lets the desktop add an
+event without breaking every shipped phone, and it is the reason the version below only moves for
+changes that are genuinely breaking.
+
+---
+
+## 3. Version handshake
+
+Two checks, at two layers, and they answer different questions.
+
+### At `auth`, before the credential is looked at
+
+`negotiateProtocolVersion()` runs first, on purpose: a client that cannot be talked to correctly
+should be told THAT, rather than being authenticated into a session where it will misbehave in
+silence. The current window is `MIN_SUPPORTED_DEVICE_PROTOCOL_VERSION = 1` to
+`DEVICE_PROTOCOL_VERSION = 1`.
+
+| Outcome | Condition | Desktop sends |
+| ---------------- | ----------------------------------- | -------------------------------------------------------------------- |
+| Accepted | `min <= v <= max` | `{op:'authenticated', protocolVersion: , ...}` |
+| `client-too-old` | `v < min` | `{op:'error', code:'protocol-version', message}` |
+| `client-too-new` | `v > max` | `{op:'error', code:'protocol-version', message}` |
+| `malformed` | Not an integer, or `< 1`, or absent | `{op:'error', code:'protocol-version', message}` |
+
+The `message` already names the end that has to update. Client-too-old says "Update the app on the
+device"; client-too-new says "Update Maestro on the desktop". **Show it verbatim.** Telling a user to
+update the wrong end of the pair is worse than saying nothing.
+
+What the client must do:
+
+- **Treat it as terminal.** No reconnect, no backoff, no silent retry. The state is a full-screen
+ message with the desktop's sentence and one action, which is not "Retry".
+- **Do not delete the Keychain item.** This is not an authentication failure; the pairing is still
+ valid and will work the moment one end is updated. Deleting the token here turns a five-minute
+ update into a re-pair.
+- **Use `authenticated.protocolVersion`, not your own constant,** in every subsequent `v`. It is the
+ lower of the two ends and it may be below the version the client compiled against.
+- **Never fall back a version you do not implement.** If the negotiated version is one the client
+ does not fully speak, say so and stop. Half-speaking a protocol is the failure mode the two-number
+ window exists to prevent.
+
+### On the data channel
+
+`welcome` and `version-rejected` are the peer-connection-level equivalents:
+`welcome` carries the agreed `version`, the desktop's `appVersion`, and the live `sessionId` or
+`null`; `version-rejected` carries the reason, a human sentence, and both `desktopVersion` and
+`minimumVersion` so the client can say which end is behind.
+
+**Desktop v1 does not emit either one.** The desktop settles the version at `auth`, so the
+data-channel handshake is currently one-sided: the client sends `hello`, the desktop records the name
+for its device list, and nothing comes back. A conforming client therefore:
+
+- sends `hello` as the first frame on `acappella-state` and **does not block on `welcome`**. Do not
+ gate the UI, the offer, or the floor button on a reply that will not arrive;
+- handles `welcome` correctly if it does arrive, because it will once the desktop side closes this
+ gap, and a client that treats it as unknown would drop the `sessionId` it carries;
+- handles `version-rejected` by closing the peer and showing its `message`, exactly as for the
+ signaling-layer rejection above.
+
+This gap is tracked by conformance items C-31 and C-32 and is the one place where this document
+describes a protocol the desktop has not finished implementing. It is stated rather than quietly
+omitted because the shapes are already frozen in `device-protocol.ts` and a Swift client will
+otherwise implement a handshake that appears to hang.
+
+### What a version bump is for
+
+`DEVICE_PROTOCOL_VERSION` moves only for a **breaking** change: a removed message type, a removed or
+retyped field, a changed channel assignment, or a changed meaning for an existing value. Adding a
+message type, an optional field, or a new `VoiceEvent` is not breaking, because both ends are
+required to ignore what they do not recognise. A client that crashes on an unknown field has turned
+an additive change into a breaking one on its own.
+
+---
+
+## 4. Required local behaviours
+
+These are not UI suggestions. They are the client's half of guarantees the desktop makes to the user,
+and a client that skips them breaks a promise made in the desktop's own settings copy.
+
+### No capture before the floor is open
+
+**Nothing is captured, encoded, or transmitted before the floor opens.** The rule falls out of the
+architecture rather than being enforced by the desktop: the phone's microphone is not sent anywhere
+until the phone opens the floor, so a client that streams early is unobservable from the desktop and
+must be caught here.
+
+Concretely, with `RTCAudioSession.useManualAudio = true` (see [[audio-session]]):
+
+- The WebRTC audio unit stays **off** while the peer connection is up and the floor is closed.
+- The outbound audio track exists but is disabled; the desktop's `set-floor-holder` gating is a second
+ line of defence, not the first.
+- `audio-level` messages are sent only while the floor is open. A meter running with the floor closed
+ is a client measuring a microphone it should not have open.
+
+### Wake word and stop word, on the device
+
+Both run locally, for the reason in [[../architecture/acappella/wake-and-hotkeys]]: a wake word cannot
+be detected remotely without sending the audio it exists to gate, and a stop word must be heard while
+the desktop is speaking.
+
+- A wake-word hit is **exactly** a `floor: press` with the selected scope. Not a `wake` voice event,
+ not a new message type. From the desktop's side there is no difference and there must not be one.
+- A stop-word hit is `interrupt: { kind: 'stop-word' }`.
+- Arming follows the desktop's rule: wake phrases only while the session is cold, stop phrases in
+ every active state, never both. Otherwise a wake phrase spoken mid-answer stacks a second session.
+- The wake-word tap is a **separate capture gate** from the WebRTC one. It runs locally, transmits
+ nothing, and still lights the system recording indicator, which is why the microphone pill in
+ [[interaction-model]] has three states rather than two.
+- Defaults come from the desktop (`DEFAULT_WAKE_PHRASE = 'hey maestro'`,
+ `DEFAULT_STOP_PHRASE = 'maestro stop'`, with `'nevermind'` always armed and not editable). Do not
+ carry a second copy of these strings as client constants.
+
+### Barge-in ducking within 20 ms
+
+While TTS is playing, a local VAD watches the microphone. On detected speech:
+
+1. **Duck local playback within 20 ms**, locally, before anything goes on the wire. One frame of
+ audio, not one round trip. A phone on a relayed path is 150 ms from the desktop and back, and a
+ user who has started talking over the reply has already decided the reply is wrong.
+2. Send `interrupt: { kind: 'barge-in' }` in the same turn of the run loop.
+3. Restore the level only on the authoritative `barge-in` **or** `speak-end` voice event. If neither
+ arrives within 500 ms, restore the level and keep the floor: a duck that never lifts is a session
+ that appears to have died.
+
+Barge-in keeps the floor. Stop word releases it. Every assistant that merged those two became one you
+cannot get rid of, and the desktop enforces the distinction: `barge-in` calls `interrupt()`,
+`stop-word` calls `hardStop()`.
+
+Both are **refused outright from a device that is not holding the floor**, with no error frame. A
+client that shows a stop button while another device holds the floor is showing a button that does
+nothing.
+
+### Floor state is never assumed
+
+- Send `press` on touch-down, not after classifying tap versus hold. The desktop's press is
+ idempotent and waiting 300 ms puts 300 ms in front of every utterance.
+- Render the button from `floor-state`, never from the local gesture. The gesture is a request.
+- Start closed after any reconnect and wait for `floor-state`. A phone that assumes it still holds
+ the floor is a hot microphone the desktop does not know about.
+- A release from a device that is not the current holder is discarded, by design: without that rule a
+ device that just lost the floor would shut the microphone of the device that just took it.
+
+---
+
+## 5. Conformance checklist
+
+Each item is independently testable and is named by the suite at
+`src/__tests__/acappella/conformance/`. An implementation is conformant when every item passes, and
+each item is written so that "passes" is observable from outside the client.
+
+The suite runs in `npm run test`, on a harness (`harness.ts`) that assembles the real desktop stack -
+`ACappellaTransport` over a real `PeerRegistry` - and drives it with the real browser reference
+client, so a frame really is encoded, crosses a loopback data channel, and comes back out of
+`decodeDeviceMessage()` on the far side. Four files, split by what fails:
+
+| File | Items | What it proves |
+| ------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| `signaling.conformance.test.ts` | C-01 to C-15 | Pairing, auth, the ordering rules, both rate limits, and what `authenticated` carries. |
+| `data-channel.conformance.test.ts` | C-16 to C-30, C-37 to C-40 | Channel labels and inits, `v`, routing, malformed frames, the event catalogue, and the microphone gate. |
+| `failure-paths.conformance.test.ts` | C-12 to C-15, C-33 to C-49 | Version mismatch, mid-session revocation, a network drop and reconnect, floor takeover, and the stop word. |
+| `../../web-desktop/acappella-client/` | C-24 to C-28, C-31 to C-36, C-44, C-47 | The client-side half, in `src/__tests__/web-desktop/acappella-client/` where the DOM and the gestures live. |
+
+A rule the desktop enforces is asserted from a raw signaling socket rather than from the reference
+client, because a limit a conforming client never reaches is a limit nobody has tested.
+
+### Signaling
+
+| ID | Requirement |
+| ---- | ------------------------------------------------------------------------------------------------------------------ |
+| C-01 | Every frame is `{type:'acappella_signal', payload:{op}}` over `/$TOKEN/ws`. No second socket, no second token. |
+| C-02 | `pair-claim` sends a non-empty `name` and the literal `platform: 'ios'`. |
+| C-03 | `pair-poll` runs on a 1 s interval and stops at the `expiresAt` from the **first** `pair-pending`. |
+| C-04 | `pair-approved` writes the token to the Keychain **before** any UI state changes. |
+| C-05 | `pair-rejected` and `auth-failed` messages are shown verbatim, with nothing appended. |
+| C-06 | `auth` always carries an integer `protocolVersion >= 1`. |
+| C-07 | No `offer` or `ice-candidate` is sent before `authenticated` arrives on the same socket. |
+| C-08 | Fewer than 6 offers are sent in any 60 s window under normal network churn. |
+| C-09 | At most one `auth` attempt per socket; a failure opens a new socket after backoff. |
+| C-10 | `authenticated.iceServers` and `iceTransportPolicy` are used as sent. No hard-coded STUN server exists in the app. |
+| C-11 | `authenticated.audio` is applied to the outgoing encoder (`fec`, `dtx`, bitrate, remote AEC request). |
+| C-12 | `closed` and `revoked` are terminal. No reconnect follows either. |
+| C-13 | `auth-failed` deletes the Keychain item and returns the app to the unpaired state. |
+| C-14 | `bye` is sent before any deliberate teardown, including backgrounding without a PTT session. |
+| C-15 | `error: peer-failed` restarts ICE and re-offers under the backoff schedule rather than re-pairing. |
+
+### Data channel
+
+| ID | Requirement |
+| ---- | ------------------------------------------------------------------------------------------------------------------------ |
+| C-16 | Both channels are created by the client, before the offer, with the exact labels `acappella-state` and `acappella-live`. |
+| C-17 | `acappella-live` is created with `{ordered:false, maxRetransmits:0}`; `acappella-state` with `{ordered:true}`. |
+| C-18 | Every outbound frame carries a numeric `v` equal to the negotiated version. |
+| C-19 | `hello` is the first frame on `acappella-state` and carries a complete `identity`. |
+| C-20 | Only the five device-originated types are ever sent. No `voice-event`, `floor-state`, `welcome`, or `revoked`. |
+| C-21 | Each message goes out on the channel the routing table names, with no exceptions. |
+| C-22 | Malformed or unknown inbound frames are ignored without closing the channel or the peer. |
+| C-23 | An unknown `voice-event.type` and an unknown field are both ignored, and processing continues. |
+| C-24 | `agent-roster` replaces the wheel wholesale; no merge, no accumulation of stale agents. |
+| C-25 | `route-correction` rewrites the existing caption in place and never appends a second row. |
+| C-26 | `speak-start.sentenceCount` is treated as a lower bound while `streaming` is true; indices past it are not clamped. |
+| C-27 | `speak-sentence` frames whose `utteranceId` is not the current run are dropped. |
+| C-28 | `mic-state` drives only the desktop indicator, never the local microphone pill. |
+| C-29 | A `seq` gap on the reliable channel is surfaced as a suspect transcript, not stitched over. |
+| C-30 | `provider-state.egressStatement` is shown verbatim wherever the app answers "where does my audio go". |
+
+### Version handshake
+
+| ID | Requirement |
+| ---- | ---------------------------------------------------------------------------------------------------------- |
+| C-31 | `hello` is sent without blocking on `welcome`; the UI, the offer, and the floor button do not wait for it. |
+| C-32 | `welcome` and `version-rejected` are both handled correctly if received. |
+| C-33 | `error: protocol-version` is terminal, shows the desktop's sentence verbatim, and offers no Retry. |
+| C-34 | A version rejection does **not** delete the Keychain item. |
+| C-35 | `authenticated.protocolVersion` is used for `v`, not the client's own constant. |
+| C-36 | A negotiated version the client does not fully implement is refused loudly rather than half-spoken. |
+
+### Local behaviour
+
+| ID | Requirement |
+| ---- | --------------------------------------------------------------------------------------------------- |
+| C-37 | No audio is captured, encoded, or transmitted before `floor-state.isSelf` is true. |
+| C-38 | `RTCAudioSession.useManualAudio` is true and the audio unit is started only for an open floor. |
+| C-39 | `audio-level` is sent at roughly 20/s while the floor is open, and never while it is closed. |
+| C-40 | `link-quality` is sent from a throttled `getStats()` at roughly 2 s intervals. |
+| C-41 | The wake word runs on the device and produces a plain `floor: press` with the selected scope. |
+| C-42 | The stop word runs on the device and produces `interrupt: {kind:'stop-word'}`. |
+| C-43 | Wake phrases arm only while the session is cold; stop phrases arm in every active state. |
+| C-44 | Local VAD ducks playback within 20 ms of detected speech, before the `interrupt` frame is sent. |
+| C-45 | The duck lifts on `barge-in` or `speak-end`, or after 500 ms with the floor kept. |
+| C-46 | Barge-in keeps the floor and the microphone; only the stop word releases them. |
+| C-47 | `floor: press` is sent on touch-down, before tap-versus-hold is classified. |
+| C-48 | The button renders `floor-state`, not the local gesture, including `takenOverBy` after a takeover. |
+| C-49 | The floor starts closed after every reconnect and waits for `floor-state`. |
+| C-50 | The microphone pill distinguishes "Mic off", "Listening for wake word", and "Sending" at all times. |
+
+---
+
+## What to read next
+
+- `src/web-desktop/acappella-client/README.md` for the browser reference client: this document, but
+ running, and the endpoint the desktop is regression-tested against.
+- [[connection-and-pairing]] for the discovery, pairing, Keychain, and reconnection behaviour these
+ messages sit inside.
+- [[audio-session]] for how `RemoteAudioConfig` and the two capture gates map onto
+ `AVAudioSession` and WebRTC.framework.
+- [[interaction-model]] for what each of these events does to the screen.
+- [[project-structure]] for where the code implementing this lives.
+- [[../architecture/acappella/voice-session-protocol]] for the desktop-side narrative behind the
+ session events.
diff --git a/docs/screenshots/acappella-models.png b/docs/screenshots/acappella-models.png
new file mode 100644
index 0000000000..0bfdb479d9
Binary files /dev/null and b/docs/screenshots/acappella-models.png differ
diff --git a/docs/screenshots/acappella-paired-devices.png b/docs/screenshots/acappella-paired-devices.png
new file mode 100644
index 0000000000..3bfc310fd2
Binary files /dev/null and b/docs/screenshots/acappella-paired-devices.png differ
diff --git a/docs/screenshots/acappella-stop-word-hotkeys.png b/docs/screenshots/acappella-stop-word-hotkeys.png
new file mode 100644
index 0000000000..2e1e6b2d98
Binary files /dev/null and b/docs/screenshots/acappella-stop-word-hotkeys.png differ
diff --git a/docs/screenshots/acappella-voice-providers.png b/docs/screenshots/acappella-voice-providers.png
new file mode 100644
index 0000000000..431c83f5df
Binary files /dev/null and b/docs/screenshots/acappella-voice-providers.png differ
diff --git a/docs/screenshots/acappella-voice-setup.png b/docs/screenshots/acappella-voice-setup.png
new file mode 100644
index 0000000000..412afda577
Binary files /dev/null and b/docs/screenshots/acappella-voice-setup.png differ
diff --git a/docs/screenshots/acappella-wake-word.png b/docs/screenshots/acappella-wake-word.png
new file mode 100644
index 0000000000..9fd0467e3f
Binary files /dev/null and b/docs/screenshots/acappella-wake-word.png differ
diff --git a/docs/voice-mode.md b/docs/voice-mode.md
new file mode 100644
index 0000000000..75e525b5e4
--- /dev/null
+++ b/docs/voice-mode.md
@@ -0,0 +1,256 @@
+---
+title: A Cappella (Voice Mode)
+description: Talk to Maestro. Speak a request, have it routed to the right agent and tab, and hear the reply read back.
+icon: microphone
+---
+
+A Cappella is Maestro's voice interface. You say something, Maestro works out which agent and which tab you meant, sends it there, and reads the reply back to you. You can drive it with a wake word, with a global hotkey from inside another application, or from a paired phone across the room.
+
+It is a **front end for the agents you already have**, not a second assistant. It does not think about your code, it does not answer questions itself, and it never invents an agent to talk to. Every utterance ends up in an ordinary Maestro tab that you can scroll back through afterwards.
+
+
+ A Cappella is an **Encore Feature** and it is in **Beta**. It is off by default. While it is off, no
+ microphone is opened, no model is downloaded, no hotkey is registered, no Bonjour advert is
+ published, and no voice UI renders anywhere in the app.
+
+
+## Turning it on
+
+1. Open **Settings** (`Cmd+,` / `Ctrl+,`) and go to the **Plugins** tab.
+2. Find the **A Cappella** card and enable it. A confirmation sheet lists what the feature reads before anything is switched on.
+3. Select the card to open its detail pane. The **Settings** sub-tab holds every voice panel: Voice Setup, Voice Providers, Voice Controls, Voice and Speed, Paired Devices, and Models.
+
+Enabling the feature only makes those panels reachable. **Nothing is downloaded and no device is opened until you ask for it**: models arrive when you press Download, and the microphone opens at your first real session.
+
+
+
+## Where your audio goes
+
+This is the only question that really matters when you configure a voice assistant, so Maestro computes the answer from your actual selection rather than writing it into help text. The sentence at the top of **Voice Providers** and **Voice and Speed** is derived from the providers you picked, and it updates the moment you change a slot.
+
+There are three independent slots plus one alternative pipeline shape:
+
+| Slot | What it does | Default |
+| ------------------- | --------------------------------------------------------- | ------- |
+| **Speech-to-Text** | Turns what you said into words | Local |
+| **Text-to-Speech** | Turns the reply into sound | Local |
+| **Conductor Brain** | Decides which agent and tab you meant, and shapes replies | Local |
+
+
+
+Each slot is configured and validated on its own. A missing Whisper model does not stop you using a hosted Brain, and **no path anywhere silently substitutes one provider for another**. If a slot cannot run, the session refuses with a specific reason instead of quietly routing your microphone somewhere you did not choose.
+
+### The providers, and what leaves your machine
+
+| Provider | Slot | Leaves this machine |
+| -------------------------------------- | --------------- | ----------------------------------------- |
+| Whisper (local) | Speech-to-Text | Nothing |
+| OpenAI (hosted) | Speech-to-Text | **Your audio**, to OpenAI |
+| Kokoro (local) | Text-to-Speech | Nothing |
+| ElevenLabs (hosted) | Text-to-Speech | The reply text, to ElevenLabs |
+| Qwen3 1.7B (local) | Conductor Brain | Nothing |
+| OpenAI (hosted) | Conductor Brain | Your transcripts, to OpenAI |
+| Anthropic (hosted) | Conductor Brain | Your transcripts, to Anthropic |
+| Conductor agent | Conductor Brain | Nothing new (it runs an agent you set up) |
+| Mock providers | Any | Nothing (no microphone, no model) |
+| **OpenAI Realtime** (speech-to-speech) | All three | **Your audio**, to OpenAI |
+
+**Realtime is a pipeline shape, not a fourth slot.** Choosing it replaces all three slots with one speech-to-speech API: the lowest latency available, in exchange for your audio going to OpenAI and the assistant speaking in that provider's voice.
+
+Hosted providers need an API key, entered in **Voice Providers**. Keys are stored per service and validated when you add them.
+
+
+ The **wake word is always local and never optional**. While only the wake detector is running, no
+ audio frame reaches a hosted provider, whichever speech-to-text engine you picked. That is enforced
+ in the type system and re-checked at runtime, not left to discipline.
+
+
+### The Conductor agent option
+
+The Conductor Brain can also be a real Maestro agent instead of a model. It is slower than the other options, and in exchange it can reason about your actual projects when deciding where an utterance belongs. Nothing new leaves your machine: it runs an agent you already configured, wherever you already configured it to run.
+
+## Downloading the models
+
+Local providers need model files. **Voice Setup** lists every one with its exact size, license, source repository, and install path before it downloads anything, and mounting the panel makes zero network calls.
+
+
+ **The local speech engines are not in this build yet.** The models below install and verify fine,
+ but the runtimes that read them (whisper.cpp, ONNX Runtime, and llama.cpp) ship in a later
+ release. Until then Voice Setup says so against each affected slot and a session refuses to start
+ rather than half-opening, so you can see it before spending the download. Use a hosted provider,
+ or wait for the runtimes.
+
+
+| Model | Role | Size | License |
+| ---------------------------- | --------------- | -------- | ---------- |
+| Whisper Base (English) | Speech-to-Text | 141.1 MB | MIT |
+| openWakeWord Base | Wake word | 2.3 MB | Apache-2.0 |
+| Kokoro 82M | Text-to-Speech | 310.9 MB | Apache-2.0 |
+| Qwen3 1.7B Instruct (Q4_K_M) | Conductor Brain | 1.0 GB | Apache-2.0 |
+
+Voice Setup offers them as two bundles, and the button always shows the total of what is still **missing** rather than the size of the whole set:
+
+- **Hands-free (local)** - Whisper, openWakeWord, and Kokoro. **454.4 MB.** Everything the microphone touches stays on this machine.
+- **Fully local** - the above plus the Conductor Brain. **1.5 GB.** Routing and spoken replies never call an API either.
+
+Every file is downloaded from a pinned revision and checked against a SHA-256 recorded in the app, so a model that was tampered with in transit fails to install rather than quietly running.
+
+### Disk usage and getting the space back
+
+Models install under `models/acappella` inside Maestro's user data directory. The **Models** page shows the total footprint, each model's size, when it was installed, and when it was last verified, with **Remove** and **Re-verify** next to each one.
+
+When you switch A Cappella **off**, the Models page stays visible and offers to reclaim the disk. It deletes only the A Cappella model directory, and it confirms first.
+
+
+
+## Wake word and stop word
+
+Both live in **Voice Controls**.
+
+### Wake word
+
+- Default phrase: **"hey maestro"**. Say it and a Conductor session opens without stealing focus from whatever you are working in.
+- **Sensitivity** is a single slider. Higher fires more easily.
+- **Test** runs the detector with no session behind it, so you can tune the sensitivity by saying the phrase instead of guessing, restarting, and guessing again. A test run closes the microphone on its own after 15 seconds.
+- **Per-agent wake phrases.** Any agent can be given its own phrase, and saying it opens a session bound directly to that agent, skipping routing entirely.
+
+The wake word needs the openWakeWord model (2.3 MB). Without it the detector runs inert and says so rather than pretending to listen.
+
+
+
+### Stop word
+
+The stop word is a separate control in its own card, because it is a different action from interrupting.
+
+| | Barge-in (just start talking) | Stop word |
+| ----------- | ------------------------------- | ------------- |
+| Means | "stop talking, I am still here" | "we are done" |
+| Speech | Cancelled | Cancelled |
+| Microphone | Stays open | Closes |
+| The session | Keeps going | Ends |
+
+- Default stop phrase: **"maestro stop"**.
+- **"nevermind" is always armed and cannot be edited.** A stop word you have to remember is not a stop word, so this one is the same in every install and works even if you have never opened the settings.
+
+Wake phrases are only listened for while the session is cold, and stop phrases only while it is running, so saying the wake word mid-answer can never stack a second session on top of the first.
+
+## The two hotkeys
+
+Both are system-wide, both ship bound, and both only register while A Cappella is on.
+
+| Hotkey | Default | What it does |
+| ------------------------- | -------------------------- | --------------------------------------------------------------------------------------------- |
+| **Talk to Maestro** | `Cmd+Alt+V` / `Ctrl+Alt+V` | Opens a Conductor session **without stealing focus**, so you can talk while working elsewhere |
+| **Talk to Current Agent** | `Cmd+Alt+A` / `Ctrl+Alt+A` | Brings Maestro forward and opens a session bound to the agent you are looking at |
+
+Rebind either one in **Voice Controls** or in the **Shortcuts** tab; they are two views of the same binding, not two settings. Voice Controls also shows each hotkey's **real registration state**, which matters because a combination another application already owns is the commonest way a global shortcut silently does nothing.
+
+
+ **The hotkeys are tap-to-toggle on every platform today.** Press once to open the floor, press again
+ to close it. Electron reports key presses but not key releases, so true press-and-hold on a global
+ hotkey would need a native module Maestro does not ship. Rather than fake it, Voice Controls says
+ which behaviour you are getting. The HUD's talk button and a paired phone's button do have a real
+ release event, so press-and-hold works there.
+
+
+
+
+You can also start a session from the microphone under the **Send button** in the composer, the **command palette** (`Cmd+K` / `Ctrl+K`, then "Talk to..."), or by right-clicking an agent in the **Left Bar**.
+
+## While a session is running
+
+A small draggable HUD appears. It remembers where you put it.
+
+The indicator has five states, told apart by shape and motion as well as colour: **idle**, **listening** (with a live input level), **thinking**, **speaking**, and **error**. Under `prefers-reduced-motion` all of it goes static.
+
+- **Minimize collapses the HUD and leaves the session running. Close ends the session.** They are deliberately different: an open microphone with no visible surface is exactly what the close button exists to prevent.
+- The **transcript** panel shows what was heard, what was sent, and which agent and tab each turn landed in. Route chips jump you straight to that tab, reopening it if you had closed or snoozed it.
+- A session that hears nothing goes cold on its own. The idle timeout defaults to **60 seconds** and is adjustable.
+
+**Voice and Speed** controls what the assistant sounds like: voice, speaking rate (0.7x to 1.4x), and its own volume, separate from the system volume. Every one of them applies to the **next spoken sentence**, so you can audition a change without restarting a conversation.
+
+**Background announcements** decide whether an agent finishing in the background gets spoken about. The default is **Auto**: yes in a Conductor session, where you are supervising a fleet and a finished agent is the news you are waiting for; no inside a session bound to one agent, where another agent talking over your conversation is an interruption you did not ask for.
+
+## Microphone permission
+
+Maestro asks for the microphone at your **first real session**, never at launch and never when you switch the Encore Feature on.
+
+| Platform | Behaviour |
+| -------- | --------------------------------------------------------------------------------------------------------- |
+| macOS | A real permission prompt. Once denied, the recovery is System Settings, and Maestro links there directly. |
+| Windows | Maestro can read the permission state but cannot prompt. The OS privacy setting is the only recovery. |
+| Linux | No permission system to query. A failure is reported when the capture attempt fails. |
+
+A denied microphone is reported as a denied microphone, with its own reason and its own fix. It is never collapsed into a generic "voice unavailable" next to 1.5 GB of perfectly good models.
+
+## Pairing a phone
+
+A paired device becomes a remote microphone and speaker for this computer. It is not a second brain: the session, the routing, and the agents all stay on the desktop.
+
+
+ The iOS app is specified but not yet built. What ships today is the **browser reference client**, a
+ small dependency-free page that speaks the identical protocol. See
+ [the client specification](https://github.com/RunMaestro/Maestro/tree/main/docs/ios-client) for the
+ native app design.
+
+
+### The flow
+
+1. In **Paired Devices**, press **Show pairing code**. Maestro displays a QR code and a 6-character pairing code, good for **two minutes** and spent by the first device that claims it.
+2. Scan it from the device. For the browser reference client, open `http://://acappella` and paste the payload behind the QR code.
+3. **Approve the request on the desktop.** Knowing the code is not enough. It buys the device a row in a dialog showing its name and platform, and nothing else.
+4. Compare the four-character fingerprint shown on both screens. Matching fingerprints mean nothing got in between.
+
+A paired device can hold this computer's microphone, hear replies in your configured voice, and dispatch spoken prompts to your agents. It cannot read your files or change your settings. The device's token is stored as a salted hash, never in plain text.
+
+**Revoke** is per-device and takes effect on a live connection immediately, tearing down the audio and closing the voice session rather than waiting for the next connect. There is also one control that drops every device at once.
+
+
+
+### Discovery
+
+Maestro can advertise itself over Bonjour so a device on the same network finds it without anyone typing an address. It is a convenience and never the connection itself: the QR code carries the addresses directly, and manual host entry always works. The advert carries the app version, protocol version, machine name, and pairing fingerprint, and never a token or a pairing code. You can switch it off.
+
+### Whether it will actually connect
+
+Each connected device shows the connection path it actually won, in plain words.
+
+| Path | Needs | Works |
+| ----------------------- | ------------------------------------ | --------------------------------------------------- |
+| Direct (LAN or overlay) | Nothing | Same WiFi or wire, or over Tailscale-style overlays |
+| Direct (through NAT) | A STUN server | Most home networks |
+| Relayed (TURN) | **A TURN server you run or pay for** | Cellular, hotel WiFi, corporate networks |
+
+If you already run an overlay network such as Tailscale, that is the whole answer: both machines have a routable address for each other and the connection is direct from anywhere. The pairing QR carries every local address the desktop has, overlay addresses included.
+
+
+ **A phone on a mobile network needs a TURN server.** Carrier-grade NAT does not support the
+ hole punching that STUN relies on, so no amount of STUN gets through it. And **the Cloudflare quick
+ tunnel that serves Maestro's browser interface cannot carry this audio**: it is an HTTPS reverse
+ proxy, while the media leg is a direct UDP association between the two devices. Signaling goes
+ through the tunnel fine, which is why "the tunnel is up" tells you nothing about whether audio will
+ flow.
+
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| The hotkey does nothing | Another application owns the combination. Voice Controls shows the real registration state and names the conflict. |
+| The wake word never fires | The openWakeWord model is not installed, or the sensitivity is too low. Use **Test** to tune it by voice. |
+| A session refuses to start | Open Voice Providers. Each slot reports its own specific reason: a missing model, a missing key, or a denied microphone. |
+| Speech works but replies are silent | Check the assistant volume in Voice and Speed. It is separate from the system volume and has its own floor. |
+| A phone pairs but no audio arrives | The media path is separate from signaling. Check the connection path shown on the device row; cellular needs TURN. |
+| Voice was disabled but disk is still in use | The Models page stays available when the feature is off, and offers to reclaim the model directory. |
+
+Two tools on the **Models** page are worth reaching for before filing a bug, and both are safe to run at any time:
+
+- **Run voice self-test** loads each speech runtime and runs a trivial operation against it. No model is loaded and no microphone is opened. Include the result when you report a voice problem.
+- **Read last turn** shows where the last spoken turn actually spent its time, from the moment the detector heard you stop talking. Include this when you report that voice feels slow.
+
+## Related
+
+- [Encore Features](./encore-features) - how optional features are gated
+- [Keyboard Shortcuts](./keyboard-shortcuts) - the full shortcut and global hotkey list
+- [Remote Control](./remote-control) - the browser interface and the Cloudflare tunnel
+- [Configuration](./configuration) - where Maestro stores its data
diff --git a/package-lock.json b/package-lock.json
index acee79bf9c..d2dede6403 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -95,6 +95,7 @@
"remark-math": "^6.0.0",
"semver": "^7.7.4",
"shiki": "^4.0.2",
+ "tar": "^7.5.17",
"ws": "^8.16.0",
"zustand": "^5.0.11"
},
@@ -2589,7 +2590,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
- "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
@@ -19078,7 +19078,6 @@
"version": "7.5.17",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz",
"integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==",
- "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@@ -19169,7 +19168,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -19179,7 +19177,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -19192,7 +19189,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
- "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
diff --git a/package.json b/package.json
index 1607a9885e..0b5dd356c4 100644
--- a/package.json
+++ b/package.json
@@ -35,16 +35,17 @@
"build:maestro-p": "node scripts/build-maestro-p.mjs",
"build:permission-relay-bridge": "node scripts/build-permission-relay-bridge.mjs",
"build:renderer": "vite build",
- "package": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac --win --linux",
- "package:mac": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac",
- "package:win": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --win",
- "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux",
+ "package": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac --win --linux && npm run verify:native-packaging",
+ "package:mac": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --mac && npm run verify:native-packaging",
+ "package:win": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --win && npm run verify:native-packaging",
+ "package:linux": "node scripts/set-version.mjs npm run build && node scripts/set-version.mjs electron-builder --linux && npm run verify:native-packaging",
+ "verify:native-packaging": "node scripts/verify-native-packaging.mjs",
"start": "electron .",
"clean": "rm -rf dist release node_modules/.vite",
"preinstall": "node scripts/check-python.mjs",
"prepare": "node scripts/setup-git-hooks.mjs",
"postinstall": "patch-package && node scripts/ensure-electron.mjs && electron-rebuild -f -w node-pty,better-sqlite3",
- "lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit",
+ "lint": "tsc -p tsconfig.lint.json && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.cli.json --noEmit && tsc -p tsconfig.scripts.json",
"lint:eslint": "eslint src/ && eslint -c eslint.dashes.config.mjs src/__tests__ scripts",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:all": "prettier --write .",
@@ -57,6 +58,8 @@
"test:e2e": "bun run build:main && bun run build:renderer && playwright test",
"test:e2e:ui": "bun run build:main && bun run build:renderer && playwright test --ui",
"test:e2e:headed": "bun run build:main && bun run build:renderer && playwright test --headed",
+ "acappella:eval": "node scripts/acappella-routing-eval.mjs",
+ "acappella:latency": "node scripts/acappella-speech-latency.mjs",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
"test:performance": "vitest run --config vitest.performance.config.mts",
@@ -95,7 +98,11 @@
"node_modules/@napi-rs/keyring/**/*",
"node_modules/@napi-rs/keyring-*/*",
"node_modules/bindings/**/*",
- "node_modules/file-uri-to-path/**/*"
+ "node_modules/file-uri-to-path/**/*",
+ "node_modules/node-llama-cpp/**/*",
+ "node_modules/@node-llama-cpp/**/*",
+ "node_modules/smart-whisper/build/**/*",
+ "node_modules/onnxruntime-node/bin/**/*"
],
"mac": {
"category": "public.app-category.developer-tools",
@@ -104,6 +111,9 @@
"notarize": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
+ "extendInfo": {
+ "NSMicrophoneUsageDescription": "Maestro uses the microphone for A Cappella, its voice control feature, so you can speak to your agents. Your audio is processed on this machine when you select local speech providers, and is sent to a hosted provider only if you choose one."
+ },
"target": [
"dmg",
"zip"
@@ -375,6 +385,7 @@
"remark-math": "^6.0.0",
"semver": "^7.7.4",
"shiki": "^4.0.2",
+ "tar": "^7.5.17",
"ws": "^8.16.0",
"zustand": "^5.0.11"
},
diff --git a/scripts/acappella-routing-eval.mjs b/scripts/acappella-routing-eval.mjs
new file mode 100644
index 0000000000..47c6f43a2b
--- /dev/null
+++ b/scripts/acappella-routing-eval.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * Runner for the A Cappella routing evaluation harness.
+ *
+ * The harness imports main-process modules (the router, the prompt manager, the
+ * routing log), so it has to be bundled before it can run outside Electron. The
+ * only thing standing in the way is `electron` itself, which two of those modules
+ * import for `app.getPath()`; it is stubbed with the three answers they need.
+ *
+ * The output lands two directories below the repo root on purpose:
+ * `prompt-manager.ts` resolves the bundled prompts as
+ * `__dirname/../../src/prompts`, so that depth is what makes the harness read the
+ * real `src/prompts/acappella-router.md` rather than the built-in fallback.
+ *
+ * See scripts/acappella-routing-eval.ts for what it measures and why.
+ */
+
+import { spawn } from 'child_process';
+import * as esbuild from 'esbuild';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..');
+const outfile = path.join(rootDir, 'dist', 'acappella-eval', 'routing-eval.cjs');
+
+/** Enough of `electron` for the prompt manager and the routing log to resolve paths. */
+const electronStub = {
+ name: 'electron-stub',
+ setup(build) {
+ build.onResolve({ filter: /^electron$/ }, () => ({
+ path: 'electron',
+ namespace: 'electron-stub',
+ }));
+ build.onLoad({ filter: /.*/, namespace: 'electron-stub' }, () => ({
+ contents: `
+ const userData = ${JSON.stringify(path.join(os.tmpdir(), 'acappella-routing-eval'))};
+ exports.app = {
+ isPackaged: false,
+ getAppPath: () => ${JSON.stringify(rootDir)},
+ getPath: () => userData,
+ getVersion: () => '0.0.0-eval',
+ on: () => {},
+ whenReady: () => Promise.resolve(),
+ };
+ exports.ipcMain = { on: () => {}, handle: () => {} };
+ exports.BrowserWindow = { getAllWindows: () => [] };
+ `,
+ loader: 'js',
+ }));
+ },
+};
+
+await esbuild.build({
+ entryPoints: [path.join(__dirname, 'acappella-routing-eval.ts')],
+ bundle: true,
+ platform: 'node',
+ target: 'node20',
+ format: 'cjs',
+ outfile,
+ sourcemap: 'inline',
+ // Native modules the main process pulls in transitively. The harness never
+ // reaches them; node-llama-cpp is loaded dynamically by the local Brain, which
+ // reports its own absence.
+ external: [
+ 'fsevents',
+ 'node-pty',
+ 'node-llama-cpp',
+ '@node-llama-cpp/*',
+ 'better-sqlite3',
+ // Ships a .node binding esbuild cannot inline. Required at runtime instead,
+ // which is harmless: the harness injects its own credential reader.
+ '@napi-rs/keyring',
+ ],
+ plugins: [electronStub],
+ logLevel: 'warning',
+});
+
+fs.mkdirSync(path.join(os.tmpdir(), 'acappella-routing-eval'), { recursive: true });
+
+const child = spawn(process.execPath, [outfile, ...process.argv.slice(2)], { stdio: 'inherit' });
+child.on('exit', (code) => process.exit(code ?? 1));
diff --git a/scripts/acappella-routing-eval.ts b/scripts/acappella-routing-eval.ts
new file mode 100644
index 0000000000..90c2627560
--- /dev/null
+++ b/scripts/acappella-routing-eval.ts
@@ -0,0 +1,841 @@
+/**
+ * A Cappella routing evaluation harness.
+ *
+ * The model-in-the-loop half of `docs/architecture/acappella/routing-evaluation.md`.
+ * The deterministic suites in `src/__tests__/main/acappella/router/` prove the
+ * router's RULES against a scripted Brain; nothing in CI proves that a real model,
+ * handed the real prompt and a realistic roster, picks the right agent and the
+ * right tab. That is the number this file produces.
+ *
+ * It was originally written down as a fifteen-minute microphone session with four
+ * live agents. That is a bad instrument for the thing being measured: routing
+ * takes a TRANSCRIPT and a ROSTER, both of which are data, so speech recognition
+ * and real agents add two uncontrolled variables and make the result unrepeatable.
+ * Everything here below the Brain is the shipping code - `createConductorRouter`,
+ * the real prompt from `src/prompts/acappella-router.md`, `parseRouteDecision`,
+ * the grammar validator, the recall ranker, and the routing log itself - so the
+ * only thing being varied is the model.
+ *
+ * The harness plays the part of the user who corrects a misroute: a decision that
+ * does not match the expectation is marked `corrected` in the routing log, which
+ * is exactly what the HUD's correction control does in the app. `routingQuality()`
+ * then reports the hit rate the same way it will report it in the field.
+ *
+ * Usage:
+ *
+ * npm run acappella:eval # the Conductor-agent Brain
+ * npm run acappella:eval -- --brain anthropic
+ * npm run acappella:eval -- --brain openai --agent-type codex
+ * npm run acappella:eval -- --brain local --model-path /path/to/qwen3.gguf
+ *
+ * Hosted Brains read their key from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` rather
+ * than the keychain, so a run does not depend on a configured desktop install.
+ */
+
+import { spawn as spawnChild, type ChildProcess } from 'child_process';
+import { EventEmitter } from 'events';
+import * as os from 'os';
+import * as path from 'path';
+
+import { AgentDetector } from '../src/main/agents';
+import {
+ createConductorRouter,
+ isCorrectionUtterance,
+ planCorrection,
+} from '../src/main/acappella/router/conductor-router';
+import {
+ ConductorAgentBrain,
+ type ConductorProcessManager,
+} from '../src/main/acappella/router/conductor-agent';
+import {
+ serializeRoutingContext,
+ type RoutingContext,
+} from '../src/main/acappella/router/routing-context';
+import {
+ noteRoutingOutcome,
+ readRoutingLog,
+ resetRoutingLog,
+ routingQuality,
+ setRoutingLogPath,
+} from '../src/main/acappella/router/routing-log';
+import { AnthropicBrainProvider } from '../src/main/acappella/providers/hosted/anthropic-brain';
+import { OpenAiBrainProvider } from '../src/main/acappella/providers/hosted/openai-brain';
+import { LlamaBrainProvider } from '../src/main/acappella/providers/local/llama-brain';
+import { initializePrompts } from '../src/main/prompt-manager';
+import { logger } from '../src/main/utils/logger';
+import type { RosterAgent } from '../src/shared/acappella/protocol';
+import type { BrainProvider } from '../src/shared/acappella/providers';
+import {
+ isClarification,
+ routeTargetSessionId,
+ type RouteDecision,
+ type RouteTabAction,
+} from '../src/shared/acappella/route-decision';
+
+// ---------------------------------------------------------------------------
+// The fixture roster
+// ---------------------------------------------------------------------------
+
+const MINUTE = 60_000;
+
+/**
+ * Fixed so two runs are comparable: recency is part of the recall ranking, and a
+ * roster built from the wall clock would score differently every time.
+ */
+const BASE_TIME = Date.parse('2026-08-15T12:00:00Z');
+
+const SESSIONS = {
+ backend: 'sess-backend',
+ api: 'sess-api',
+ frontend: 'sess-frontend',
+ infra: 'sess-infra',
+} as const;
+
+/** The four agents and twelve tabs the evaluation doc specifies. */
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: SESSIONS.backend,
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/payments-api',
+ status: 'idle',
+ recentWork: 'Split the auth middleware out of the request pipeline',
+ tabs: [
+ {
+ id: 'tab-auth-refactor',
+ name: 'Auth Refactor',
+ lastActiveAt: BASE_TIME - 2 * MINUTE,
+ state: 'open',
+ topic: 'rewriting the auth middleware and its token checks',
+ },
+ {
+ id: 'tab-db-migrations',
+ name: 'DB Migrations',
+ lastActiveAt: BASE_TIME - 90 * MINUTE,
+ state: 'open',
+ topic: 'the pending payment schema migrations',
+ },
+ {
+ id: 'tab-rate-limit-spike',
+ name: 'Rate Limit Spike',
+ lastActiveAt: BASE_TIME - 26 * 60 * MINUTE,
+ state: 'snoozed',
+ topic: 'an abandoned experiment with per-key rate limiting',
+ },
+ ],
+ },
+ {
+ sessionId: SESSIONS.api,
+ name: 'API',
+ agentType: 'codex',
+ cwd: '/repo/gateway',
+ status: 'idle',
+ recentWork: 'Added retry backoff to the webhook dispatcher',
+ tabs: [
+ {
+ id: 'tab-gateway-routing',
+ name: 'Gateway Routing',
+ lastActiveAt: BASE_TIME - 15 * MINUTE,
+ state: 'open',
+ topic: 'how requests are routed through the gateway',
+ },
+ {
+ id: 'tab-webhook-retries',
+ name: 'Webhook Retries',
+ lastActiveAt: BASE_TIME - 4 * 60 * MINUTE,
+ state: 'open',
+ topic: 'what the retry policy for failed webhooks should be',
+ },
+ {
+ id: 'tab-old-auth-spike',
+ name: 'Old Auth Spike',
+ lastActiveAt: BASE_TIME - 12 * 24 * 60 * MINUTE,
+ state: 'closed',
+ topic: 'an early attempt at gateway-side auth, abandoned',
+ },
+ ],
+ },
+ {
+ sessionId: SESSIONS.frontend,
+ name: 'Frontend',
+ agentType: 'claude-code',
+ cwd: '/repo/web',
+ status: 'busy',
+ recentWork: 'Made the sidebar collapse animation respect reduced motion',
+ tabs: [
+ {
+ id: 'tab-sidebar-collapse',
+ name: 'Sidebar Collapse',
+ lastActiveAt: BASE_TIME - 8 * MINUTE,
+ state: 'open',
+ topic: 'the collapsing sidebar and its animation',
+ },
+ {
+ id: 'tab-checkout-flow',
+ name: 'Checkout Flow',
+ lastActiveAt: BASE_TIME - 6 * 60 * MINUTE,
+ state: 'open',
+ topic: 'the multi-step checkout form and its validation',
+ },
+ {
+ id: 'tab-dark-mode',
+ name: 'Dark Mode',
+ lastActiveAt: BASE_TIME - 3 * 24 * 60 * MINUTE,
+ state: 'open',
+ topic: 'the dark mode toggle and how the choice is remembered',
+ },
+ ],
+ },
+ {
+ sessionId: SESSIONS.infra,
+ name: 'Infra',
+ agentType: 'opencode',
+ cwd: '/repo/terraform',
+ status: 'idle',
+ recentWork: 'Pinned the node pool to the previous Kubernetes minor',
+ tabs: [
+ {
+ id: 'tab-cluster-upgrade',
+ name: 'Cluster Upgrade',
+ lastActiveAt: BASE_TIME - 40 * MINUTE,
+ state: 'open',
+ topic: 'upgrading the Kubernetes cluster version',
+ },
+ {
+ id: 'tab-cost-report',
+ name: 'Cost Report',
+ lastActiveAt: BASE_TIME - 2 * 24 * 60 * MINUTE,
+ state: 'open',
+ topic: 'the monthly cloud spend breakdown',
+ },
+ {
+ id: 'tab-log-retention',
+ name: 'Log Retention',
+ lastActiveAt: BASE_TIME - 5 * 24 * 60 * MINUTE,
+ state: 'open',
+ topic: 'how long logs are kept before they are rolled off',
+ },
+ ],
+ },
+];
+
+/** The agent the user is looking at when the script starts. */
+const ACTIVE_AGENT = SESSIONS.backend;
+
+// ---------------------------------------------------------------------------
+// The script
+// ---------------------------------------------------------------------------
+
+interface Expectation {
+ /**
+ * A roster session id, or `conductor`.
+ *
+ * Omitted for a case whose correct answer is a question: when the router is
+ * right to be unsure, which target it leaned toward while asking is not part
+ * of being right, and scoring it would penalise the intended behaviour.
+ */
+ target?: string;
+ action?: RouteTabAction;
+ tabId?: string;
+ /** True when the correct behaviour is a spoken question rather than a dispatch. */
+ clarify?: boolean;
+}
+
+interface EvalCase {
+ n: number;
+ utterance: string;
+ tests: string;
+ expect: Expectation;
+ /**
+ * A correction is not routed at all: it is recognised before the Brain is
+ * consulted, which is the property worth testing.
+ */
+ correction?: boolean;
+ /**
+ * The answer to the question this utterance is expected to provoke, routed on
+ * the next turn with the original utterance attached. Reported separately: it
+ * measures the disambiguation round trip, not the fifteen-utterance hit rate.
+ */
+ answer?: { text: string; target: string };
+}
+
+const CASES: EvalCase[] = [
+ {
+ n: 1,
+ utterance: 'run the tests',
+ tests: 'same-topic continuation',
+ expect: { target: SESSIONS.backend, action: 'current' },
+ },
+ {
+ n: 2,
+ utterance: 'what broke',
+ tests: 'pronoun-free follow-up',
+ expect: { target: SESSIONS.backend, action: 'current' },
+ },
+ {
+ n: 3,
+ utterance: 'add a rate limiter to the public endpoints',
+ tests: 'topic switch',
+ expect: { target: SESSIONS.backend, action: 'new' },
+ },
+ {
+ n: 4,
+ utterance: 'ask the frontend agent about the checkout flow',
+ tests: 'explicit agent naming',
+ expect: { target: SESSIONS.frontend, action: 'recall', tabId: 'tab-checkout-flow' },
+ },
+ {
+ n: 5,
+ utterance: 'tell infra to bump the cluster version',
+ tests: 'explicit agent naming',
+ expect: { target: SESSIONS.infra, action: 'current' },
+ },
+ {
+ n: 6,
+ utterance: 'back to the auth thing',
+ tests: 'vague recall',
+ expect: { target: SESSIONS.backend, action: 'recall', tabId: 'tab-auth-refactor' },
+ },
+ {
+ n: 7,
+ utterance: 'what did we decide about webhook retries',
+ tests: 'recall by topic',
+ expect: { target: SESSIONS.api, action: 'recall', tabId: 'tab-webhook-retries' },
+ },
+ {
+ n: 8,
+ utterance: 'the gateway one',
+ tests: 'recall by project path',
+ expect: { target: SESSIONS.api },
+ },
+ {
+ n: 9,
+ utterance: 'pick up that rate limit spike again',
+ tests: 'snoozed-tab wake',
+ expect: { target: SESSIONS.backend, action: 'recall', tabId: 'tab-rate-limit-spike' },
+ },
+ {
+ n: 10,
+ utterance: 'go back to the old auth spike',
+ tests: 'closed-tab reopen offer',
+ // A closed tab is an offer, not a dispatch: `applyRecallPolicy` attaches the
+ // question, so the correct decision here CARRIES a clarify.
+ expect: {
+ target: SESSIONS.api,
+ action: 'recall',
+ tabId: 'tab-old-auth-spike',
+ clarify: true,
+ },
+ },
+ {
+ n: 11,
+ utterance: 'how many agents do I have running',
+ tests: 'Maestro-level question',
+ expect: { target: 'conductor' },
+ },
+ {
+ n: 12,
+ utterance: 'which one is busy right now',
+ tests: 'fleet-level question',
+ expect: { target: 'conductor' },
+ },
+ {
+ n: 13,
+ utterance: 'make the dark mode toggle stick',
+ tests: 'topic match over recency',
+ expect: { target: SESSIONS.frontend, action: 'recall', tabId: 'tab-dark-mode' },
+ },
+ {
+ n: 14,
+ utterance: 'do the auth one',
+ tests: 'low confidence disambiguation',
+ // "Auth Refactor" on Backend and "Old Auth Spike" on API both fit, so the
+ // only right answer is a question. Which one it leaned toward is not scored.
+ expect: { clarify: true },
+ answer: { text: 'the backend one', target: SESSIONS.backend },
+ },
+ {
+ n: 15,
+ utterance: 'no, the other one',
+ tests: 'correction path',
+ expect: {},
+ correction: true,
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Brains
+// ---------------------------------------------------------------------------
+
+type BrainKey = 'agent' | 'anthropic' | 'openai' | 'local';
+
+interface Options {
+ brain: BrainKey;
+ agentType: string;
+ cwd: string;
+ modelPath?: string;
+ model?: string;
+ json: boolean;
+}
+
+/**
+ * A `ConductorProcessManager` backed by `child_process`.
+ *
+ * The real `ProcessManager` cannot run here: it needs Electron, node-pty and the
+ * session machinery. The interface is structural for exactly this reason, and the
+ * two behaviours the Brain depends on are reproduced faithfully:
+ *
+ * - Prompt delivery follows the no-image branch of `ChildProcessSpawner.spawn()`
+ * (`promptArgs`, else `noPromptSeparator`, else a `--` separator).
+ * - The `data` event carries the RESULT text, not raw stdout, the way
+ * `ExitHandler.handleBatchModeExit()` emits it: parse the buffer as JSON and
+ * emit `.result`, falling back to the raw buffer when it is not JSON.
+ */
+class SpawnProcessManager extends EventEmitter implements ConductorProcessManager {
+ private readonly children = new Map();
+
+ spawn(config: Record): { pid: number; success: boolean } | null {
+ const sessionId = String(config.sessionId);
+ const command = String(config.command);
+ const args = [...((config.args as string[]) ?? [])];
+ const prompt = String(config.prompt ?? '');
+ const promptArgs = config.promptArgs as ((value: string) => string[]) | undefined;
+
+ if (promptArgs) args.push(...promptArgs(prompt));
+ else if (config.noPromptSeparator) args.push(prompt);
+ else args.push('--', prompt);
+
+ const child = spawnChild(command, args, {
+ cwd: String(config.cwd),
+ env: {
+ ...childEnv(),
+ ...((config.customEnvVars as Record) ?? {}),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ this.children.set(sessionId, child);
+
+ let buffer = '';
+ child.stdout?.on('data', (chunk: Buffer) => {
+ buffer += chunk.toString('utf8');
+ });
+ child.stderr?.on('data', (chunk: Buffer) => {
+ process.stderr.write(chunk);
+ });
+ child.on('error', (error) => {
+ this.children.delete(sessionId);
+ this.emit('data', sessionId, `[error] ${error.message}`);
+ this.emit('exit', sessionId, 1);
+ });
+ child.on('close', () => {
+ this.children.delete(sessionId);
+ this.emit('data', sessionId, extractResult(buffer));
+ this.emit('exit', sessionId, 0);
+ });
+
+ return { pid: child.pid ?? -1, success: true };
+ }
+
+ kill(sessionId: string): void {
+ this.children.get(sessionId)?.kill('SIGTERM');
+ this.children.delete(sessionId);
+ }
+}
+
+/** What `ExitHandler.handleBatchModeExit()` would have emitted for this buffer. */
+function extractResult(buffer: string): string {
+ try {
+ const parsed = JSON.parse(buffer) as { result?: unknown };
+ if (typeof parsed.result === 'string') return parsed.result;
+ } catch {
+ /* not JSON: the raw buffer is the answer, same as the real handler */
+ }
+ return buffer;
+}
+
+/**
+ * The environment a spawned agent gets.
+ *
+ * The Claude session-identity markers are stripped for the same reason
+ * `sanitizeChildEnv()` in `src/maestro-p/index.ts` strips them: inherited from the
+ * shell an agent is running in, they make the child believe it is a nested session.
+ */
+function childEnv(): NodeJS.ProcessEnv {
+ const env = { ...process.env };
+ for (const key of [
+ 'CLAUDECODE',
+ 'CLAUDE_CODE_SESSION_ID',
+ 'CLAUDE_CODE_CHILD_SESSION',
+ 'CLAUDE_CODE_ENTRYPOINT',
+ ]) {
+ delete env[key];
+ }
+ return env;
+}
+
+async function createBrain(options: Options): Promise {
+ switch (options.brain) {
+ case 'anthropic':
+ return new AnthropicBrainProvider({
+ model: options.model,
+ readCredential: () => process.env.ANTHROPIC_API_KEY?.trim() || null,
+ });
+
+ case 'openai':
+ return new OpenAiBrainProvider({
+ model: options.model,
+ readCredential: () => process.env.OPENAI_API_KEY?.trim() || null,
+ });
+
+ case 'local':
+ return new LlamaBrainProvider({ modelPath: options.modelPath });
+
+ case 'agent': {
+ const detector = new AgentDetector();
+ const real = await detector.getAgent(options.agentType);
+ if (!real || !real.available) {
+ throw new Error(
+ `The '${options.agentType}' agent is not installed. Install it, or pass --brain anthropic.`
+ );
+ }
+ return new ConductorAgentBrain({
+ processManager: new SpawnProcessManager(),
+ // A one-shot JSON batch run. The shipping default is stream-json, which
+ // this harness's process manager does not reassemble; `--output-format
+ // json` produces the single envelope `extractResult()` reads.
+ agentDetector: {
+ getAgent: async () => ({ ...real, args: batchArgsFor(real.args ?? []) }),
+ } as unknown as AgentDetector,
+ agentType: options.agentType,
+ cwd: options.cwd,
+ // A real model may think for a while; the shipping 20s deadline is a
+ // voice budget, not an evaluation one.
+ timeoutMs: 120_000,
+ modelId: options.model,
+ });
+ }
+ }
+}
+
+/** Swap a streaming output format for the single-envelope one. */
+function batchArgsFor(args: readonly string[]): string[] {
+ const out: string[] = [];
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--output-format') {
+ out.push('--output-format', 'json');
+ i++;
+ continue;
+ }
+ if (args[i] === '--verbose') continue;
+ out.push(args[i]);
+ }
+ if (!out.includes('--output-format')) out.push('--output-format', 'json');
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// The run
+// ---------------------------------------------------------------------------
+
+interface CaseResult {
+ n: number;
+ utterance: string;
+ tests: string;
+ expected: string;
+ actual: string;
+ hit: boolean;
+ confidence: number | null;
+ latencyMs: number | null;
+ note?: string;
+}
+
+async function run(options: Options): Promise {
+ // Agent detection alone logs a screenful of PATH probes. The result table is
+ // the output of this script; a real problem still comes through as an error.
+ logger.setLogLevel('error');
+
+ // The evaluation must measure the SHIPPING prompt, including any local edit,
+ // not the built-in fallback constant.
+ await initializePrompts();
+
+ const brain = await createBrain(options);
+ await probeBrain(brain);
+
+ // A file, not a stub: the harness reports through the same instrument the field
+ // reports through, so a hit rate here and a hit rate in the app mean one thing.
+ setRoutingLogPath(path.join(os.tmpdir(), `acappella-routing-eval-${process.pid}.json`));
+ resetRoutingLog();
+
+ const recentUtterances: string[] = [];
+ const context: RoutingContext = {
+ agents: ROSTER,
+ activeAgentSessionId: ACTIVE_AGENT,
+ recentUtterances,
+ droppedTabs: 0,
+ serializedChars: 0,
+ };
+ context.serializedChars = serializeRoutingContext(context).length;
+
+ const router = createConductorRouter({
+ brain,
+ loadContext: async (recent) => ({ ...context, recentUtterances: [...recent] }),
+ });
+
+ const results: CaseResult[] = [];
+ let lastDispatchTarget: string | null = null;
+
+ for (const testCase of CASES) {
+ if (testCase.correction) {
+ results.push(evaluateCorrection(testCase, lastDispatchTarget));
+ continue;
+ }
+
+ const startedAt = Date.now();
+ let decision: RouteDecision;
+ try {
+ decision = await router.route(testCase.utterance, {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ activeAgentSessionId: ACTIVE_AGENT,
+ recentUtterances: [...recentUtterances],
+ });
+ } catch (error) {
+ results.push({
+ n: testCase.n,
+ utterance: testCase.utterance,
+ tests: testCase.tests,
+ expected: describeExpectation(testCase.expect),
+ actual: `error: ${(error as Error).message}`,
+ hit: false,
+ confidence: null,
+ latencyMs: Date.now() - startedAt,
+ });
+ continue;
+ }
+
+ const hit = matches(decision, testCase.expect);
+ const turnId = router.lastTurnId();
+ if (turnId && !hit) {
+ // Exactly what the HUD's correction control does, so the log's hit rate is
+ // the number this harness reports rather than a second tally beside it.
+ noteRoutingOutcome(turnId, 'corrected', `expected ${describeExpectation(testCase.expect)}`);
+ }
+
+ const entry = readRoutingLog().find((candidate) => candidate.id === turnId);
+ results.push({
+ n: testCase.n,
+ utterance: testCase.utterance,
+ tests: testCase.tests,
+ expected: describeExpectation(testCase.expect),
+ actual: describeDecision(decision),
+ hit,
+ confidence: decision.confidence,
+ latencyMs: entry?.latencyMs ?? Date.now() - startedAt,
+ });
+
+ recentUtterances.push(testCase.utterance);
+ if (!isClarification(decision)) {
+ lastDispatchTarget = routeTargetSessionId(decision.target);
+ }
+
+ if (testCase.answer && isClarification(decision)) {
+ results.push(await evaluateAnswer(router, testCase, decision, recentUtterances));
+ }
+ }
+
+ report(options, brain, results);
+}
+
+/**
+ * One throwaway decision before the script starts.
+ *
+ * A missing key or an uninstalled runtime fails identically on all fifteen
+ * utterances, and fifteen copies of "no API key is configured" printed inside a
+ * results table reads like a routing result. This turns it back into what it is:
+ * the Brain could not be reached, so there is nothing to measure.
+ */
+async function probeBrain(brain: BrainProvider): Promise {
+ try {
+ await brain.route('hello', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ activeAgentSessionId: ACTIVE_AGENT,
+ });
+ } catch (error) {
+ throw new Error(`${brain.label} is not usable here: ${(error as Error).message}`);
+ }
+}
+
+/**
+ * The turn after a disambiguation.
+ *
+ * Routed with `clarification` set, which is what stops "the backend one" from
+ * being treated as a request and becoming a tab called "the backend one".
+ */
+async function evaluateAnswer(
+ router: ReturnType,
+ testCase: EvalCase,
+ question: RouteDecision,
+ recentUtterances: string[]
+): Promise {
+ const answer = testCase.answer!;
+ const startedAt = Date.now();
+ const decision = await router.route(answer.text, {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ activeAgentSessionId: ACTIVE_AGENT,
+ recentUtterances: [...recentUtterances],
+ clarification: { question: question.clarify!, utterance: testCase.utterance },
+ });
+
+ const hit = matches(decision, { target: answer.target });
+ const turnId = router.lastTurnId();
+ if (turnId && !hit) noteRoutingOutcome(turnId, 'corrected', `expected ${answer.target}`);
+
+ return {
+ n: testCase.n,
+ utterance: answer.text,
+ tests: 'disambiguation answer',
+ expected: answer.target,
+ actual: describeDecision(decision),
+ hit,
+ confidence: decision.confidence,
+ latencyMs: Date.now() - startedAt,
+ note: 'follow-up, excluded from the fifteen',
+ };
+}
+
+/**
+ * A correction never reaches the Brain.
+ *
+ * Recognised from the utterance alone and turned into a plan against the roster,
+ * so what is checked here is the recognition and the plan, not a decision.
+ */
+function evaluateCorrection(testCase: EvalCase, fromAgent: string | null): CaseResult {
+ const recognised = isCorrectionUtterance(testCase.utterance);
+ const plan = recognised ? planCorrection(ROSTER, fromAgent ?? '') : null;
+ // Four agents means "the other one" does not name anything, so asking is the
+ // only honest plan. A `move` here would mean the router guessed twice.
+ const hit = recognised && plan?.kind === 'ask';
+
+ return {
+ n: testCase.n,
+ utterance: testCase.utterance,
+ tests: testCase.tests,
+ expected: 'recognised as a correction, asks which target',
+ actual: recognised ? `correction -> ${plan?.kind}` : 'not recognised as a correction',
+ hit: Boolean(hit),
+ confidence: null,
+ latencyMs: null,
+ note: 'not routed, excluded from the hit rate',
+ };
+}
+
+function matches(decision: RouteDecision, expected: Expectation): boolean {
+ const target = routeTargetSessionId(decision.target) ?? 'conductor';
+ if (expected.target && target !== expected.target) return false;
+ if (Boolean(expected.clarify) !== isClarification(decision)) return false;
+ if (expected.action && decision.tabAction !== expected.action) return false;
+ if (expected.tabId && decision.tabId !== expected.tabId) return false;
+ return true;
+}
+
+function describeExpectation(expected: Expectation): string {
+ const parts = [expected.target ? nameOf(expected.target) : 'any target'];
+ if (expected.action) parts.push(expected.action);
+ if (expected.tabId) parts.push(expected.tabId);
+ if (expected.clarify) parts.push('clarify');
+ return parts.join(' / ');
+}
+
+function describeDecision(decision: RouteDecision): string {
+ const parts = [nameOf(routeTargetSessionId(decision.target) ?? 'conductor'), decision.tabAction];
+ if (decision.tabId) parts.push(decision.tabId);
+ if (decision.tabName) parts.push(`"${decision.tabName}"`);
+ if (isClarification(decision)) parts.push('clarify');
+ return parts.join(' / ');
+}
+
+function nameOf(sessionId: string): string {
+ return ROSTER.find((agent) => agent.sessionId === sessionId)?.name ?? sessionId;
+}
+
+// ---------------------------------------------------------------------------
+// Reporting
+// ---------------------------------------------------------------------------
+
+function report(options: Options, brain: BrainProvider, results: CaseResult[]): void {
+ const quality = routingQuality();
+
+ if (options.json) {
+ console.log(JSON.stringify({ brain: brain.id, results, quality }, null, 2));
+ return;
+ }
+
+ console.log('');
+ console.log(`Brain: ${brain.label} (${brain.id})`);
+ console.log('');
+ console.log('| # | Utterance | Expected | Actual | Conf | ms | Hit |');
+ console.log('| --- | --------- | -------- | ------ | ---- | -- | --- |');
+ for (const result of results) {
+ console.log(
+ `| ${result.n} | ${result.utterance} | ${result.expected} | ${result.actual} | ` +
+ `${result.confidence?.toFixed(2) ?? '-'} | ${result.latencyMs ?? '-'} | ` +
+ `${result.hit ? 'yes' : 'NO'} |`
+ );
+ }
+
+ const scored = results.filter((result) => !result.note);
+ const hits = scored.filter((result) => result.hit).length;
+
+ console.log('');
+ console.log(
+ `Script: ${hits}/${scored.length} routed utterances matched. ` +
+ 'The fifteenth is a correction and is never routed; the extra 14 is the answer to its question.'
+ );
+ console.log(
+ `Routing log: ${quality.dispatched} dispatched, ${quality.corrected} corrected, ` +
+ `${quality.clarified} clarified, ${quality.failed} failed.`
+ );
+ console.log(
+ `Hit rate: ${quality.hitRate === null ? '-' : `${(quality.hitRate * 100).toFixed(0)}%`}, ` +
+ `mean latency: ${quality.meanLatencyMs ?? '-'} ms.`
+ );
+ console.log('');
+ console.log('Paste the row into docs/architecture/acappella/routing-evaluation.md.');
+}
+
+// ---------------------------------------------------------------------------
+// Entry
+// ---------------------------------------------------------------------------
+
+function parseArgs(argv: string[]): Options {
+ const options: Options = {
+ brain: 'agent',
+ agentType: 'claude-code',
+ cwd: process.cwd(),
+ json: false,
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--brain') options.brain = argv[++i] as BrainKey;
+ else if (arg === '--agent-type') options.agentType = argv[++i];
+ else if (arg === '--cwd') options.cwd = argv[++i];
+ else if (arg === '--model-path') options.modelPath = argv[++i];
+ else if (arg === '--model') options.model = argv[++i];
+ else if (arg === '--json') options.json = true;
+ else throw new Error(`Unknown option: ${arg}`);
+ }
+
+ if (!['agent', 'anthropic', 'openai', 'local'].includes(options.brain)) {
+ throw new Error(`Unknown brain: ${options.brain}`);
+ }
+ return options;
+}
+
+run(parseArgs(process.argv.slice(2))).catch((error: Error) => {
+ console.error(`Routing evaluation failed: ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/acappella-speech-latency.mjs b/scripts/acappella-speech-latency.mjs
new file mode 100644
index 0000000000..25fa0916ae
--- /dev/null
+++ b/scripts/acappella-speech-latency.mjs
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+/**
+ * Runner for the A Cappella speech latency harness.
+ *
+ * Same shape as `acappella-routing-eval.mjs` and for the same reason: the harness
+ * imports main-process modules, so it is bundled before it can run outside
+ * Electron, with `electron` itself stubbed down to the handful of answers those
+ * modules want for path resolution.
+ *
+ * See scripts/acappella-speech-latency.ts for what it measures and why.
+ */
+
+import { spawn } from 'child_process';
+import * as esbuild from 'esbuild';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..');
+const outfile = path.join(rootDir, 'dist', 'acappella-eval', 'speech-latency.cjs');
+
+/** Enough of `electron` for the bundled main-process modules to resolve paths. */
+const electronStub = {
+ name: 'electron-stub',
+ setup(build) {
+ build.onResolve({ filter: /^electron$/ }, () => ({
+ path: 'electron',
+ namespace: 'electron-stub',
+ }));
+ build.onLoad({ filter: /.*/, namespace: 'electron-stub' }, () => ({
+ contents: `
+ const userData = ${JSON.stringify(path.join(os.tmpdir(), 'acappella-speech-latency'))};
+ exports.app = {
+ isPackaged: false,
+ getAppPath: () => ${JSON.stringify(rootDir)},
+ getPath: () => userData,
+ getVersion: () => '0.0.0-latency',
+ on: () => {},
+ whenReady: () => Promise.resolve(),
+ };
+ exports.ipcMain = { on: () => {}, handle: () => {} };
+ exports.BrowserWindow = { getAllWindows: () => [] };
+ `,
+ loader: 'js',
+ }));
+ },
+};
+
+await esbuild.build({
+ entryPoints: [path.join(__dirname, 'acappella-speech-latency.ts')],
+ bundle: true,
+ platform: 'node',
+ target: 'node20',
+ format: 'cjs',
+ outfile,
+ sourcemap: 'inline',
+ external: [
+ 'fsevents',
+ 'node-pty',
+ 'node-llama-cpp',
+ '@node-llama-cpp/*',
+ 'better-sqlite3',
+ '@napi-rs/keyring',
+ ],
+ plugins: [electronStub],
+ logLevel: 'warning',
+});
+
+fs.mkdirSync(path.join(os.tmpdir(), 'acappella-speech-latency'), { recursive: true });
+
+const child = spawn(process.execPath, [outfile, ...process.argv.slice(2)], { stdio: 'inherit' });
+child.on('exit', (code) => process.exit(code ?? 1));
diff --git a/scripts/acappella-speech-latency.ts b/scripts/acappella-speech-latency.ts
new file mode 100644
index 0000000000..58ce326547
--- /dev/null
+++ b/scripts/acappella-speech-latency.ts
@@ -0,0 +1,610 @@
+/**
+ * A Cappella speech latency harness.
+ *
+ * The measurement half of the "Time to first spoken word" section in
+ * `docs/architecture/acappella/latency-baseline.md`. That number is the one a
+ * user actually feels - how long they stand in silence before anything is said -
+ * and everything in `src/main/acappella/speech/` exists to shorten it.
+ *
+ * Measuring it with a microphone and a live agent measures four things at once:
+ * the decode, the model on the day, the network on the day, and the streaming
+ * layer. Only the last of those is ours, and it is the only one a regression can
+ * be attributed to. So the providers here are stubs with DECLARED costs and
+ * everything between them is the shipping code - `AgentOutputTap`,
+ * `ConversationalTranslator`, `SpeechScheduler`, and the one splitter in
+ * `src/shared/acappella/sentences.ts`. Vary the declared costs and the arms move
+ * together; vary the layer and only the streamed arm moves. That is the property
+ * that makes this a baseline rather than a benchmark.
+ *
+ * Two arms per fixture, and the comparison between them IS the result:
+ *
+ * - `streamed` - the shipped path. The tap cuts at a completed thought while
+ * the agent is still writing, the translator rewrites that piece alone, and
+ * the scheduler speaks it.
+ * - `buffered` - the counterfactual the layer replaced: wait for the whole
+ * reply, rewrite the whole thing, then speak.
+ *
+ * The zero point is the agent's first token, not the detector's speech end. That
+ * is deliberate and it matches the doc's own definition of the **First spoken
+ * sentence** span: STT and routing happen before this layer is involved, and
+ * folding them in would hide the thing being measured behind two hops the tap
+ * cannot affect.
+ *
+ * What it does NOT measure, and must not be read as measuring: the realtime
+ * pipeline. A speech-to-speech provider produces audio directly, so none of this
+ * code runs and its span is the provider's own. That row in the doc stays empty
+ * until someone records it with a key and a microphone.
+ *
+ * Usage:
+ *
+ * npm run acappella:latency # both cascade profiles
+ * npm run acappella:latency -- --profile hosted
+ * npm run acappella:latency -- --runs 3 --json
+ *
+ * A full run takes a few minutes, and that is the fixtures rather than the
+ * harness: a long agent reply takes a long time to write, and shortening it would
+ * shorten the very silence the layer exists to fill.
+ */
+
+import { EventEmitter } from 'events';
+
+import { createAgentOutputTap } from '../src/main/acappella/speech/agent-output-tap';
+import { ConversationalTranslator } from '../src/main/acappella/speech/conversational-translator';
+import { SpeechScheduler } from '../src/main/acappella/speech/speech-scheduler';
+import { buildProcessSessionId } from '../src/main/dispatch-callbacks/dispatch-callback-registry';
+import type {
+ BrainProvider,
+ TtsChunk,
+ TtsProvider,
+ VoiceConverseContext,
+} from '../src/shared/acappella/providers';
+import type { RouteDecision } from '../src/shared/acappella/route-decision';
+
+// ---------------------------------------------------------------------------
+// Provider cost profiles
+// ---------------------------------------------------------------------------
+
+/**
+ * What a hop costs. Round numbers on purpose: these are the harness's INPUT, not
+ * a claim about any provider on any day. They exist so the two arms are compared
+ * under the same conditions, and so a change in the layer shows up as a change in
+ * the gap between them rather than as noise.
+ */
+interface Profile {
+ key: string;
+ label: string;
+ /** Silence before the rewrite's first token. A local model load is not included. */
+ brainFirstTokenMs: number;
+ /** Between rewrite tokens, once it has started. */
+ brainTokenMs: number;
+ /** Synthesis of one sentence, before any of its audio exists. */
+ ttsBaseMs: number;
+ /** Added per character of the sentence being synthesised. */
+ ttsPerCharMs: number;
+ /** How fast the agent itself writes, in characters per second. */
+ agentCharsPerSecond: number;
+}
+
+const PROFILES: Profile[] = [
+ {
+ key: 'local',
+ label: 'Fully local cascade (Qwen3 Brain, Kokoro TTS)',
+ brainFirstTokenMs: 320,
+ brainTokenMs: 12,
+ ttsBaseMs: 180,
+ ttsPerCharMs: 1.2,
+ agentCharsPerSecond: 220,
+ },
+ {
+ key: 'hosted',
+ label: 'Fully hosted cascade (OpenAI Brain, ElevenLabs TTS)',
+ brainFirstTokenMs: 480,
+ brainTokenMs: 8,
+ ttsBaseMs: 260,
+ ttsPerCharMs: 0.6,
+ agentCharsPerSecond: 220,
+ },
+];
+
+/**
+ * Speaking rate used to simulate playback offline, in characters per second.
+ *
+ * Roughly 150 words a minute, which is unhurried assistant speech. Only the
+ * inter-sentence gap depends on it: a gap exists when the next sentence's audio
+ * is not ready by the time the current one stops being audible.
+ */
+const SPEECH_CHARS_PER_SECOND = 14;
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+interface Fixture {
+ key: string;
+ label: string;
+ text: string;
+}
+
+/** A paragraph of the long summary, repeated with varying detail. */
+function summaryParagraph(n: number): string {
+ return [
+ `Step ${n}: reworked the token check so a refresh that lands mid-request is not`,
+ `treated as an expiry. The old path compared the issued-at stamp against the`,
+ `request clock, which drifts, so a request that arrived during a refresh was`,
+ `rejected and the client retried into the same window. It now compares against`,
+ `the session's own high-water mark, which only moves forward.`,
+ ].join(' ');
+}
+
+/**
+ * The three shapes an agent replies in, at the sizes they really are.
+ *
+ * Size is not decoration here: the whole point of the tap is that a long reply
+ * takes a long time to WRITE, so shrinking the fixtures to make the harness quick
+ * would shrink the effect being measured. These are trimmed to the smallest size
+ * that still takes tens of seconds to produce, which is what makes a run a few
+ * minutes rather than a coffee break.
+ */
+const FIXTURES: Fixture[] = [
+ {
+ key: 'long-summary',
+ label: 'A long implementation summary (about 100 lines)',
+ text: [
+ 'I fixed the authentication bug. It was a stale token check in the refresh path.',
+ '',
+ ...Array.from({ length: 20 }, (_, i) => [summaryParagraph(i + 1), '']).flat(),
+ 'All 214 tests pass and the lint is clean.',
+ ].join('\n'),
+ },
+ {
+ key: 'diff-heavy',
+ label: 'A diff-heavy reply',
+ text: [
+ 'Here is the change to the middleware.',
+ '',
+ '```diff',
+ '--- a/src/auth/middleware.ts',
+ '+++ b/src/auth/middleware.ts',
+ ...Array.from({ length: 40 }, (_, i) => `-\tconst stale${i} = issuedAt < now;`),
+ ...Array.from({ length: 40 }, (_, i) => `+\tconst stale${i} = issuedAt < highWater;`),
+ '```',
+ '',
+ 'The high-water mark only moves forward, so a refresh landing mid-request is no',
+ 'longer read as an expiry and the client stops retrying into the same window.',
+ ].join('\n'),
+ },
+ {
+ key: 'confirmation',
+ label: 'A one-line confirmation',
+ text: 'Yes, the tests pass.',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Stub providers
+// ---------------------------------------------------------------------------
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * A Brain that writes a plausible spoken rewrite at the profile's declared rate.
+ *
+ * The content is fixed rather than generated: this harness measures WHEN words
+ * arrive, and a model deciding what to say would make two runs incomparable. The
+ * shape is what matters - two short sentences, no markdown - because that is what
+ * the translator and the splitter downstream have to handle.
+ */
+function stubBrain(profile: Profile): BrainProvider {
+ async function* write(agentText: string, context: VoiceConverseContext): AsyncIterable {
+ await sleep(profile.brainFirstTokenMs);
+ const words = rewriteFor(agentText).split(' ');
+ for (const word of words) {
+ if (context.signal?.aborted) return;
+ yield `${word} `;
+ await sleep(profile.brainTokenMs);
+ }
+ }
+
+ return {
+ id: 'stub-brain',
+ label: 'Stub Brain',
+ tier: 'mock',
+ route: (): Promise => {
+ throw new Error('the latency harness never routes');
+ },
+ converse: async (agentText, context) => {
+ let whole = '';
+ for await (const delta of write(agentText, context)) whole += delta;
+ return whole.trim();
+ },
+ converseStream: write,
+ };
+}
+
+/** Two sentences and nothing markdown-shaped, sized from the source. */
+function rewriteFor(agentText: string): string {
+ if (agentText.length < 200) return 'Yes, everything passes.';
+ return (
+ 'Done, I fixed the auth bug and it turned out to be a stale token check. ' +
+ 'Everything passes now.'
+ );
+}
+
+/** Synthesis whose cost scales with the sentence, which is how real TTS behaves. */
+function stubTts(profile: Profile): TtsProvider {
+ let cancelled = false;
+ let index = 0;
+
+ return {
+ id: 'stub-tts',
+ label: 'Stub TTS',
+ tier: 'mock',
+ cancel: () => {
+ cancelled = true;
+ },
+ speak: async function* (text: string, options): AsyncIterable {
+ await sleep(profile.ttsBaseMs + text.length * profile.ttsPerCharMs);
+ if (cancelled) return;
+ yield {
+ utteranceId: options.utteranceId,
+ index: index++,
+ text,
+ format: 'none',
+ audio: null,
+ };
+ },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// One measured turn
+// ---------------------------------------------------------------------------
+
+interface TurnResult {
+ /** Agent first token to the first sentence whose audio exists. */
+ firstSpokenWordMs: number;
+ /**
+ * The same, ignoring the tap's own status lines.
+ *
+ * These are not the same number and conflating them flatters the result. A
+ * reply the layer cannot start speaking still produces sound at the twenty
+ * second mark, because the tap says the agent is still working rather than
+ * going silent. Counting that as the first spoken word would score the safety
+ * net as if it were the answer.
+ */
+ firstAnswerWordMs: number;
+ /** Longest silence between two sentences, once playback is simulated. */
+ maxGapMs: number;
+ meanGapMs: number;
+ sentences: number;
+ /** The rewrite reached the model, or was passed through untouched. */
+ translated: boolean;
+ /**
+ * When each sentence's audio existed, in order.
+ *
+ * Reported under `--json` because an aggregate gap is not diagnosable: a long
+ * one can be the layer stalling or the agent writing something unspeakable,
+ * and only the stamps say which.
+ */
+ stamps: { at: number; chars: number; status: boolean }[];
+}
+
+const AGENT_SESSION_ID = 'sess-backend';
+const TAB_ID = 'tab-auth-refactor';
+
+/**
+ * Run one fixture through the real layer and record when each sentence's audio
+ * became available.
+ *
+ * `arm` decides only WHEN the agent's text reaches the tap: `streamed` writes it
+ * at the profile's rate, `buffered` withholds every character until the reply is
+ * finished. Nothing else differs, so the difference in the result is the tap.
+ */
+async function measureTurn(
+ profile: Profile,
+ fixture: Fixture,
+ arm: 'streamed' | 'buffered'
+): Promise {
+ const source = new EventEmitter();
+ const processSessionId = buildProcessSessionId(AGENT_SESSION_ID, TAB_ID);
+ const brain = stubBrain(profile);
+ const translator = new ConversationalTranslator({ brain });
+
+ /** When each sentence's audio existed, relative to the agent's first token. */
+ const available: { text: string; at: number; status: boolean }[] = [];
+ /**
+ * Sentences the tap produced about itself rather than about the answer.
+ *
+ * A status chunk is passed through the translator verbatim, so its sentences
+ * arrive at the scheduler with the text they were written with and can be told
+ * apart by identity. Threading a kind through the scheduler instead would put
+ * a harness concern into the protocol.
+ */
+ const statusSentences = new Set();
+ let startedAt = 0;
+
+ const scheduler = new SpeechScheduler({
+ tts: stubTts(profile),
+ onStart: () => {},
+ onSentence: (event) =>
+ available.push({
+ text: event.text,
+ at: Date.now() - startedAt,
+ status: statusSentences.has(event.text),
+ }),
+ onEnd: () => {},
+ });
+
+ const translations: Promise[] = [];
+ const tap = createAgentOutputTap({
+ source,
+ onChunk: (chunk) => {
+ translations.push(
+ (async () => {
+ for await (const sentence of translator.translate({
+ agentSessionId: chunk.agentSessionId,
+ tabId: chunk.tabId,
+ text: chunk.text,
+ kind: chunk.kind,
+ })) {
+ if (chunk.kind === 'status') statusSentences.add(sentence);
+ scheduler.pushSentence(sentence);
+ }
+ })()
+ );
+ },
+ });
+
+ tap.watch({ agentSessionId: AGENT_SESSION_ID, tabId: TAB_ID });
+ scheduler.begin(`utt-${fixture.key}-${arm}`);
+ startedAt = Date.now();
+
+ await writeAgentOutput(source, processSessionId, fixture.text, profile, arm);
+ source.emit('query-complete', processSessionId);
+
+ // Every rewrite that the tap started has to finish before the run can be
+ // closed, or the scheduler would end on a gap in the translation rather than
+ // on the end of the reply.
+ await Promise.all(translations);
+ scheduler.close();
+ await scheduler.drained();
+ tap.dispose();
+
+ return summarise(available, translator.stats.translations > 0);
+}
+
+/**
+ * Feed the agent's reply to the tap the way the process manager would.
+ *
+ * `buffered` is not "one event at the end of an instant reply": the agent takes
+ * just as long to write either way. Withholding the text until the reply is
+ * finished, at the same write rate, is what makes the arms comparable.
+ */
+async function writeAgentOutput(
+ source: EventEmitter,
+ processSessionId: string,
+ text: string,
+ profile: Profile,
+ arm: 'streamed' | 'buffered'
+): Promise {
+ const perEvent = 120;
+ const delay = (perEvent / profile.agentCharsPerSecond) * 1000;
+
+ if (arm === 'buffered') {
+ // The same write time as the streamed arm, spent in one silence instead of
+ // spread across events. Anything else would compare two different agents.
+ const writeMs = (Math.max(0, text.length - perEvent) / profile.agentCharsPerSecond) * 1000;
+ await sleep(writeMs);
+ source.emit('data', processSessionId, text);
+ return;
+ }
+
+ for (let i = 0; i < text.length; i += perEvent) {
+ // Before the write, not after: a sleep following the LAST event would be
+ // charged to the layer as silence the agent had already stopped producing,
+ // and on a one-line reply that artefact is bigger than the thing measured.
+ if (i > 0) await sleep(delay);
+ source.emit('data', processSessionId, text.slice(i, i + perEvent));
+ }
+}
+
+/**
+ * Turn availability stamps into the two numbers the doc asks for.
+ *
+ * The gap is simulated rather than observed because the scheduler hands audio to
+ * a sink and does not wait for it to be heard. A sentence's audio being ready
+ * before the previous one stops being audible is exactly the no-gap property, and
+ * it is computable from the stamps: play serially at a speaking rate and see
+ * where the player runs out of material.
+ */
+function summarise(
+ available: { text: string; at: number; status: boolean }[],
+ translated: boolean
+): TurnResult {
+ const stamps = available.map((entry) => ({
+ at: entry.at,
+ chars: entry.text.length,
+ status: entry.status,
+ }));
+ if (available.length === 0) {
+ return {
+ firstSpokenWordMs: -1,
+ firstAnswerWordMs: -1,
+ maxGapMs: 0,
+ meanGapMs: 0,
+ sentences: 0,
+ translated,
+ stamps,
+ };
+ }
+
+ const gaps: number[] = [];
+ let playbackEnd = available[0].at;
+ for (let i = 0; i < available.length; i++) {
+ const start = i === 0 ? available[0].at : Math.max(playbackEnd, available[i].at);
+ if (i > 0) gaps.push(Math.max(0, available[i].at - playbackEnd));
+ playbackEnd = start + (available[i].text.length / SPEECH_CHARS_PER_SECOND) * 1000;
+ }
+
+ return {
+ firstSpokenWordMs: available[0].at,
+ firstAnswerWordMs: available.find((entry) => !entry.status)?.at ?? -1,
+ maxGapMs: gaps.length ? Math.max(...gaps) : 0,
+ meanGapMs: gaps.length ? gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length : 0,
+ sentences: available.length,
+ translated,
+ stamps,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The run
+// ---------------------------------------------------------------------------
+
+interface Row {
+ profile: string;
+ fixture: string;
+ arm: 'streamed' | 'buffered';
+ firstSpokenWordMs: number;
+ firstAnswerWordMs: number;
+ maxGapMs: number;
+ meanGapMs: number;
+ sentences: number;
+ translated: boolean;
+ /** First run's stamps, for diagnosing a gap rather than just reporting one. */
+ stamps: { at: number; chars: number; status: boolean }[];
+}
+
+interface Options {
+ profiles: Profile[];
+ runs: number;
+ json: boolean;
+}
+
+async function run(options: Options): Promise {
+ const rows: Row[] = [];
+
+ for (const profile of options.profiles) {
+ for (const fixture of FIXTURES) {
+ for (const arm of ['streamed', 'buffered'] as const) {
+ const results: TurnResult[] = [];
+ for (let i = 0; i < options.runs; i++) {
+ results.push(await measureTurn(profile, fixture, arm));
+ }
+ rows.push({
+ profile: profile.key,
+ fixture: fixture.key,
+ arm,
+ firstSpokenWordMs: median(results.map((r) => r.firstSpokenWordMs)),
+ firstAnswerWordMs: median(results.map((r) => r.firstAnswerWordMs)),
+ maxGapMs: median(results.map((r) => r.maxGapMs)),
+ meanGapMs: median(results.map((r) => r.meanGapMs)),
+ sentences: results[0].sentences,
+ translated: results[0].translated,
+ stamps: results[0].stamps,
+ });
+ }
+ }
+ }
+
+ report(options, rows);
+}
+
+/** Median, because the first run of anything pays for a warm-up nobody hears twice. */
+function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ const value = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : (sorted[mid] ?? 0);
+ return Math.round(value);
+}
+
+function report(options: Options, rows: Row[]): void {
+ if (options.json) {
+ console.log(JSON.stringify({ rows, speechCharsPerSecond: SPEECH_CHARS_PER_SECOND }, null, 2));
+ return;
+ }
+
+ console.log('');
+ console.log(`Runs per cell: ${options.runs} (median reported). Zero point: agent first token.`);
+
+ for (const profile of options.profiles) {
+ console.log('');
+ console.log(profile.label);
+ console.log('');
+ console.log(
+ '| Fixture | Arm | First sound | First word of the answer | Max gap | Sentences | Rewrite |'
+ );
+ console.log(
+ '| ------- | --- | ----------- | ------------------------ | ------- | --------- | ------- |'
+ );
+ for (const row of rows.filter((candidate) => candidate.profile === profile.key)) {
+ console.log(
+ `| ${row.fixture} | ${row.arm} | ${row.firstSpokenWordMs} ms | ` +
+ `${row.firstAnswerWordMs} ms | ${row.maxGapMs} ms | ${row.sentences} | ` +
+ `${row.translated ? 'model' : 'passthrough'} |`
+ );
+ }
+ }
+
+ console.log('');
+ for (const profile of options.profiles) {
+ for (const fixture of FIXTURES) {
+ const streamed = find(rows, profile.key, fixture.key, 'streamed');
+ const buffered = find(rows, profile.key, fixture.key, 'buffered');
+ if (!streamed || !buffered) continue;
+ // Against the answer, not against the first sound: the buffered arm's first
+ // sound on a long reply is the tap's twenty second hang notice, and scoring
+ // the layer against its own safety net would understate it by a factor of
+ // three.
+ const saved = buffered.firstAnswerWordMs - streamed.firstAnswerWordMs;
+ console.log(
+ `${profile.key}/${fixture.key}: the tap saves ${saved} ms of silence ` +
+ `(${buffered.firstAnswerWordMs} ms buffered, ${streamed.firstAnswerWordMs} ms streamed).`
+ );
+ }
+ }
+
+ console.log('');
+ console.log('Paste the rows into docs/architecture/acappella/latency-baseline.md.');
+ console.log('Realtime is not measured here: that provider speaks directly and this layer');
+ console.log('never runs. Its row stays empty until it is recorded with a key and a mic.');
+}
+
+function find(rows: Row[], profile: string, fixture: string, arm: Row['arm']): Row | undefined {
+ return rows.find((row) => row.profile === profile && row.fixture === fixture && row.arm === arm);
+}
+
+// ---------------------------------------------------------------------------
+// Entry
+// ---------------------------------------------------------------------------
+
+function parseArgs(argv: string[]): Options {
+ let profileKey = 'both';
+ // One by default. A cell's cost is dominated by declared sleeps rather than by
+ // machine load, so repeats buy little, and a fixture that takes half a minute
+ // to write makes three of everything a five minute wait.
+ let runs = 1;
+ let json = false;
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--profile') profileKey = argv[++i];
+ else if (arg === '--runs') runs = Number(argv[++i]);
+ else if (arg === '--json') json = true;
+ else throw new Error(`Unknown option: ${arg}`);
+ }
+
+ const profiles =
+ profileKey === 'both' ? PROFILES : PROFILES.filter((profile) => profile.key === profileKey);
+ if (profiles.length === 0) throw new Error(`Unknown profile: ${profileKey}`);
+ if (!Number.isFinite(runs) || runs < 1) throw new Error(`Runs must be at least 1`);
+
+ return { profiles, runs, json };
+}
+
+run(parseArgs(process.argv.slice(2))).catch((error: Error) => {
+ console.error(`Speech latency harness failed: ${error.message}`);
+ process.exit(1);
+});
diff --git a/scripts/verify-native-packaging.mjs b/scripts/verify-native-packaging.mjs
new file mode 100644
index 0000000000..f7b78481ef
--- /dev/null
+++ b/scripts/verify-native-packaging.mjs
@@ -0,0 +1,271 @@
+#!/usr/bin/env node
+/**
+ * Packaging assertion for A Cappella's native runtimes.
+ *
+ * The bug this exists to catch does not appear in development. A native module
+ * left inside `app.asar`, or a per-platform prebuild that never got copied,
+ * works perfectly from source and fails only in the installed, signed app, on
+ * someone else's machine, after release. That is the most expensive kind of bug
+ * this codebase can ship, and it is entirely mechanical to detect.
+ *
+ * So: after packaging, walk the built app and assert, per runtime,
+ *
+ * 1. the package is present at all,
+ * 2. its platform binary exists at the path the registry promises,
+ * 3. that binary is UNPACKED (inside `app.asar.unpacked`, not `app.asar`),
+ * 4. on macOS, that every nested binary carries a signature, because
+ * notarization rejects a bundle containing an unsigned nested binary and
+ * the rejection arrives long after the build.
+ *
+ * A runtime whose package is not a dependency yet is reported and skipped: this
+ * script fails on things that are broken, not on work that has not started.
+ * Passing `--require-all` turns those skips into failures, which is what a
+ * release build should use once the providers land.
+ *
+ * The runtime facts come from `dist/shared/acappella/native-runtimes.js`, the
+ * compiled copy of the one registry the app itself reads, so this script cannot
+ * drift from what the loader expects.
+ *
+ * Usage:
+ * node scripts/verify-native-packaging.mjs [--app ]
+ * [--require-all] [--json]
+ */
+
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+const args = process.argv.slice(2);
+const requireAll = args.includes('--require-all');
+const asJson = args.includes('--json');
+const appArgIndex = args.indexOf('--app');
+const appArg = appArgIndex >= 0 ? args[appArgIndex + 1] : null;
+
+/** Load the compiled runtime registry. Built by `npm run build:main`. */
+function loadRegistry() {
+ const compiled = path.join(repoRoot, 'dist', 'shared', 'acappella', 'native-runtimes.js');
+ if (!fs.existsSync(compiled)) {
+ fail(
+ `Runtime registry not found at ${rel(compiled)}. Run "npm run build:main" before this script.`
+ );
+ }
+ return require(compiled);
+}
+
+function rel(target) {
+ return path.relative(repoRoot, target) || target;
+}
+
+function fail(message) {
+ console.error(`\nverify-native-packaging: ${message}\n`);
+ process.exit(1);
+}
+
+/**
+ * Find the packaged app's resources directory.
+ *
+ * Handles the three layouts electron-builder produces: a macOS .app bundle, a
+ * Windows/Linux unpacked directory, and the `release/` tree containing either.
+ */
+function resolveResourcesDir() {
+ const candidates = [];
+ if (appArg) candidates.push(path.resolve(appArg));
+
+ const releaseDir = path.join(repoRoot, 'release');
+ if (fs.existsSync(releaseDir)) {
+ for (const entry of fs.readdirSync(releaseDir)) {
+ candidates.push(path.join(releaseDir, entry));
+ }
+ }
+
+ for (const candidate of candidates) {
+ if (!fs.existsSync(candidate)) continue;
+
+ // macOS: .app/Contents/Resources
+ if (candidate.endsWith('.app')) {
+ const macResources = path.join(candidate, 'Contents', 'Resources');
+ if (fs.existsSync(macResources)) return { resources: macResources, app: candidate };
+ }
+
+ // A directory holding a .app (release/mac-arm64/Maestro.app)
+ if (fs.statSync(candidate).isDirectory()) {
+ const nested = fs
+ .readdirSync(candidate)
+ .filter((entry) => entry.endsWith('.app'))
+ .map((entry) => path.join(candidate, entry, 'Contents', 'Resources'))
+ .find((entry) => fs.existsSync(entry));
+ if (nested) return { resources: nested, app: path.dirname(path.dirname(nested)) };
+
+ // Windows/Linux: /resources
+ const resources = path.join(candidate, 'resources');
+ if (fs.existsSync(resources)) return { resources, app: candidate };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Whether a Mach-O binary carries a signature.
+ *
+ * `codesign --verify` on each nested binary rather than one `--deep` pass on the
+ * bundle: --deep reports the first failure and stops, and the useful output here
+ * is the full list of what is unsigned.
+ */
+function isSigned(binaryPath) {
+ try {
+ execFileSync('codesign', ['--verify', '--strict', binaryPath], { stdio: 'pipe' });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function main() {
+ const { NATIVE_RUNTIMES, nativePlatformKey } = loadRegistry();
+ const platformKey = nativePlatformKey(process.platform, process.arch);
+ if (!platformKey) {
+ fail(`No packaging matrix for ${process.platform}-${process.arch}.`);
+ }
+
+ const located = resolveResourcesDir();
+ if (!located) {
+ fail(
+ 'No packaged app found. Run an electron-builder target first, or pass --app .'
+ );
+ }
+
+ const unpackedRoot = path.join(located.resources, 'app.asar.unpacked');
+
+ // The package.json inside the bundle is what actually shipped, which is the
+ // only honest answer to "is this runtime a dependency of THIS build".
+ const shippedDeps = readShippedDependencies(located.resources);
+
+ const results = [];
+ let failures = 0;
+ let skipped = 0;
+
+ for (const runtime of NATIVE_RUNTIMES) {
+ const expected = runtime.packagedBinaries[platformKey] ?? [];
+ const declared = runtime.declared;
+ const shipped = shippedDeps ? shippedDeps.has(runtime.moduleId) : declared;
+
+ // Not a dependency yet: nothing is broken, so this is a skip. `--require-all`
+ // is how a release build says "by now they should all be here".
+ if (!declared) {
+ // Counted as one or the other, never both: a line that reads "3 failed, 3
+ // skipped" over three runtimes is a summary nobody can act on.
+ if (requireAll) failures += 1;
+ else skipped += 1;
+ results.push({
+ runtime: runtime.id,
+ moduleId: runtime.moduleId,
+ status: requireAll ? 'fail' : 'skip',
+ reason: `${runtime.moduleId} is not a dependency of this build yet (registry declared=false).`,
+ });
+ continue;
+ }
+
+ // Declared but absent from what shipped is always a packaging bug: the app
+ // will try to load it and will not find it.
+ if (!shipped) {
+ failures += 1;
+ results.push({
+ runtime: runtime.id,
+ moduleId: runtime.moduleId,
+ status: 'fail',
+ reason: `${runtime.moduleId} is a declared runtime but is missing from the packaged dependencies.`,
+ });
+ continue;
+ }
+
+ if (runtime.prebuilds[platformKey] === 'unavailable') {
+ results.push({
+ runtime: runtime.id,
+ moduleId: runtime.moduleId,
+ status: 'skip',
+ reason: `No build of ${runtime.moduleId} exists for ${platformKey}.`,
+ });
+ skipped += 1;
+ continue;
+ }
+
+ const missing = [];
+ const unsigned = [];
+
+ for (const relativePath of expected) {
+ const normalized = relativePath.split('/').join(path.sep);
+ const absolute = path.join(unpackedRoot, normalized);
+ if (!fs.existsSync(absolute)) {
+ missing.push(relativePath);
+ continue;
+ }
+ if (process.platform === 'darwin' && !isSigned(absolute)) unsigned.push(relativePath);
+ }
+
+ const problems = [];
+ if (missing.length) {
+ // Both causes are named, because the evidence here cannot tell them apart
+ // without reading the asar index and the fixes differ: either the file
+ // never made it into the build, or it is inside app.asar with no
+ // asarUnpack entry, which works from source and dies once installed.
+ problems.push(
+ `not in app.asar.unpacked (absent from the build, or still packed inside app.asar): ${missing.join(', ')}`
+ );
+ }
+ if (unsigned.length) problems.push(`unsigned: ${unsigned.join(', ')}`);
+
+ if (problems.length) failures += 1;
+ results.push({
+ runtime: runtime.id,
+ moduleId: runtime.moduleId,
+ status: problems.length ? 'fail' : 'pass',
+ reason: problems.join('; ') || `${expected.length} binaries present and signed.`,
+ });
+ }
+
+ if (asJson) {
+ console.log(JSON.stringify({ platformKey, app: located.app, results }, null, 2));
+ } else {
+ console.log(`\nNative packaging check: ${located.app} (${platformKey})`);
+ for (const result of results) {
+ console.log(
+ ` ${result.status.toUpperCase().padEnd(5)} ${result.moduleId}: ${result.reason}`
+ );
+ }
+ // Never silently truncate: a skipped runtime is stated, because "3 passed"
+ // over a list where two were skipped reads as full coverage.
+ console.log(` ${results.length} runtimes, ${failures} failed, ${skipped} skipped\n`);
+ }
+
+ if (failures > 0) {
+ fail(`${failures} native runtime(s) are not correctly packaged. See the list above.`);
+ }
+}
+
+/** Dependencies of the package.json that actually shipped inside the bundle. */
+function readShippedDependencies(resourcesDir) {
+ const candidates = [
+ path.join(resourcesDir, 'app.asar.unpacked', 'package.json'),
+ path.join(resourcesDir, 'app', 'package.json'),
+ ];
+ for (const candidate of candidates) {
+ if (!fs.existsSync(candidate)) continue;
+ try {
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
+ return new Set(Object.keys(parsed.dependencies ?? {}));
+ } catch {
+ // A package.json we cannot read tells us nothing; fall through to the
+ // registry's own view rather than failing the build on a parse error.
+ return null;
+ }
+ }
+ return null;
+}
+
+main();
diff --git a/src/__tests__/acappella/conformance/data-channel.conformance.test.ts b/src/__tests__/acappella/conformance/data-channel.conformance.test.ts
new file mode 100644
index 0000000000..c3fda9bffa
--- /dev/null
+++ b/src/__tests__/acappella/conformance/data-channel.conformance.test.ts
@@ -0,0 +1,368 @@
+/**
+ * Protocol conformance: the data channel, C-16 to C-30 and C-37 to C-40.
+ *
+ * Every frame here really crosses a channel: it is encoded by the reference
+ * client, decoded by `decodeDeviceMessage()` inside the desktop's own peer, and
+ * whatever comes back is decoded again on the way in. A message that the
+ * desktop drops is dropped in complete silence, which is exactly why these are
+ * asserted against `world.deviceMessages` (what the desktop DECODED) rather
+ * than against what the client believes it sent.
+ *
+ * The checklist items about drawing (C-24 to C-28) are asserted here as their
+ * wire half: that the event arrives intact and unclamped, so the client has
+ * what it needs. Their UI half lives in
+ * `src/__tests__/web-desktop/acappella-client/ui.test.tsx`.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ DEVICE_ORIGINATED_MESSAGES,
+ RELIABLE_CHANNEL_LABEL,
+ UNRELIABLE_CHANNEL_LABEL,
+ deviceChannelForMessage,
+ type DeviceMessage,
+} from '../../../shared/acappella/device-protocol';
+import type { RosterAgent, VoiceEvent } from '../../../shared/acappella/protocol';
+import {
+ createConformanceWorld,
+ voiceEventsFrom,
+ type ConformanceClient,
+ type ConformanceWorld,
+} from './harness';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+vi.mock('../../../renderer/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let world: ConformanceWorld;
+let device: ConformanceClient;
+
+/** Take the floor properly: press, let the desktop grant it, wait for the mic. */
+async function openFloor(): Promise {
+ device.client.pressFloor();
+ await world.advance();
+ world.session.emit({
+ type: 'listen-start',
+ scope: { kind: 'conductor' },
+ sttProviderId: 'local',
+ });
+ await world.advance();
+}
+
+beforeEach(async () => {
+ world = await createConformanceWorld();
+ device = await world.connectClient();
+});
+
+afterEach(async () => {
+ await world.dispose();
+});
+
+describe('conformance: channels', () => {
+ it('creates both channels with the exact labels and inits, before the offer (C-16, C-17)', () => {
+ expect(device.peer().channel(RELIABLE_CHANNEL_LABEL).init).toEqual({ ordered: true });
+ expect(device.peer().channel(UNRELIABLE_CHANNEL_LABEL).init).toEqual({
+ ordered: false,
+ maxRetransmits: 0,
+ });
+ // Both existed before the SDP crossed the wire, so the first offer already
+ // carried the SCTP association.
+ expect(device.peer().localDescription?.type).toBe('offer');
+ expect(device.peer().channels).toHaveLength(2);
+ });
+
+ it('closes a channel whose label it does not recognise, without a word (C-16)', async () => {
+ const rogue = device.peer().createDataChannel('acappella-typo', { ordered: true });
+ await world.advance();
+
+ expect(rogue.readyState).toBe('closed');
+ // And the two real ones are untouched.
+ expect(device.peer().channel(RELIABLE_CHANNEL_LABEL).readyState).toBe('open');
+ expect(device.peer().channel(UNRELIABLE_CHANNEL_LABEL).readyState).toBe('open');
+ });
+
+ it('stamps every outbound frame with the negotiated version (C-18, C-35)', async () => {
+ await openFloor();
+ device.client.requestBargeIn();
+ await world.advance();
+
+ const negotiated = device.state().protocolVersion;
+ expect(negotiated).toBeGreaterThanOrEqual(1);
+ const frames = device.rawSentFrames();
+ expect(frames.length).toBeGreaterThan(0);
+ for (const frame of frames) expect(frame.v).toBe(negotiated);
+ });
+
+ it('sends hello first on the state channel, with a complete identity (C-19)', () => {
+ const [first] = device.sentFrames('reliable');
+ expect(first).toMatchObject({
+ type: 'hello',
+ identity: { deviceId: device.deviceId, name: device.name, platform: 'ios' },
+ });
+ // And the desktop decoded it, which is what puts the name on the device row.
+ expect(world.deviceMessages[0]).toMatchObject({
+ deviceId: device.deviceId,
+ message: { type: 'hello' },
+ });
+ });
+
+ it('sends only the five device-originated types, on the channels the table names (C-20, C-21)', async () => {
+ await openFloor();
+ device.client.pressFloor({ kind: 'agent', sessionId: 'agent-7' });
+ device.client.reportAudioLevel(0.4, true);
+ device.client.requestStop();
+ await world.advance(2500);
+
+ for (const { message } of world.deviceMessages) {
+ expect(DEVICE_ORIGINATED_MESSAGES).toContain(message.type);
+ }
+ const reliable = device.sentFrames('reliable');
+ const unreliable = device.sentFrames('unreliable');
+ for (const message of reliable) expect(deviceChannelForMessage(message)).toBe('reliable');
+ for (const message of unreliable) expect(deviceChannelForMessage(message)).toBe('unreliable');
+ expect(unreliable.map((message) => message.type)).toContain('floor');
+ expect(unreliable.map((message) => message.type)).toContain('interrupt');
+ });
+
+ it('puts each desktop message on the channel the table names too (C-21)', async () => {
+ world.session.emit({ type: 'partial-transcript', text: 'open the', stability: 0.4 });
+ world.session.emit({ type: 'final-transcript', text: 'open the auth tab' });
+ await world.advance();
+
+ const reliable = device.receivedFrames('reliable');
+ const unreliable = device.receivedFrames('unreliable');
+ for (const message of reliable) expect(deviceChannelForMessage(message)).toBe('reliable');
+ for (const message of unreliable) expect(deviceChannelForMessage(message)).toBe('unreliable');
+ expect(voiceEventsFrom(unreliable, 'partial-transcript')).toHaveLength(1);
+ expect(voiceEventsFrom(reliable, 'final-transcript')).toHaveLength(1);
+ });
+});
+
+describe('conformance: malformed and unknown frames', () => {
+ it('drops a malformed frame from a device without closing anything (C-22)', async () => {
+ const live = device.peer().channel(UNRELIABLE_CHANNEL_LABEL);
+ live.send('not json');
+ live.send(JSON.stringify({ type: 'floor', action: 'press' })); // no `v`
+ live.send(JSON.stringify({ type: 'invented', v: 1 }));
+ live.send(JSON.stringify({ type: 'floor', action: 'sideways', v: 1 }));
+ await world.advance();
+
+ expect(world.deviceMessages.filter((entry) => entry.message.type === 'floor')).toHaveLength(0);
+ expect(live.readyState).toBe('open');
+ // The connection is still fully usable afterwards.
+ device.client.pressFloor();
+ await world.advance();
+ expect(world.deviceMessages.some((entry) => entry.message.type === 'floor')).toBe(true);
+ });
+
+ it('drops a malformed frame from the desktop without closing anything (C-22)', async () => {
+ const channel = device.desktopPeer().channel(RELIABLE_CHANNEL_LABEL);
+ channel.send('not json');
+ channel.send(JSON.stringify({ type: 'floor-state', holder: 'someone', isSelf: true }));
+ await world.advance();
+
+ expect(device.state().phase).toBe('connected');
+ expect(device.state().floor.isSelf).toBe(false);
+ expect(device.peer().channel(RELIABLE_CHANNEL_LABEL).readyState).toBe('open');
+ });
+
+ it('ignores an unknown voice event and keeps processing the stream (C-23)', async () => {
+ world.session.emit({ type: 'invented-event' as VoiceEvent['type'] });
+ world.session.emit({ type: 'listen-stop', reason: 'stopped' });
+ await world.advance();
+
+ expect(device.state().phase).toBe('connected');
+ expect(voiceEventsFrom(device.receivedFrames(), 'listen-stop')).toHaveLength(1);
+ });
+
+ it('flags a seq gap on the reliable channel rather than stitching over it (C-29)', async () => {
+ world.session.emit({ type: 'listen-stop', reason: 'stopped' });
+ await world.advance();
+ expect(device.state().transcriptSuspect).toBe(false);
+
+ // Five events the client never saw.
+ world.session.seq += 5;
+ world.session.emit({ type: 'listen-stop', reason: 'stopped' });
+ await world.advance();
+ expect(device.state().transcriptSuspect).toBe(true);
+ });
+});
+
+describe('conformance: the session-event catalogue round-trips', () => {
+ it('carries an agent roster the client can replace its wheel from (C-24)', async () => {
+ const agents: RosterAgent[] = [
+ {
+ sessionId: 'agent-1',
+ name: 'acappella',
+ agentType: 'claude-code',
+ status: 'idle',
+ cwd: '/tmp/one',
+ tabs: [{ id: 'tab-1', name: 'Phase 11', lastActiveAt: 42, state: 'open' }],
+ },
+ ];
+ world.session.emit({ type: 'agent-roster', agents });
+ await world.advance();
+
+ const [roster] = voiceEventsFrom(device.receivedFrames(), 'agent-roster');
+ expect(roster).toMatchObject({ type: 'agent-roster', agents });
+ });
+
+ it('carries a route correction as its own event, with both targets (C-25)', async () => {
+ world.session.emit({
+ type: 'route-correction',
+ fromAgentSessionId: 'agent-1',
+ fromTabId: 'tab-1',
+ agentSessionId: 'agent-2',
+ agentName: 'the other one',
+ tabId: 'tab-9',
+ action: 'created',
+ promptSent: true,
+ source: 'voice',
+ });
+ await world.advance();
+
+ // Both ends of the correction travel, which is what lets a client rewrite
+ // the caption it already drew instead of appending a second row.
+ const [correction] = voiceEventsFrom(device.receivedFrames(), 'route-correction');
+ expect(correction).toMatchObject({
+ fromAgentSessionId: 'agent-1',
+ fromTabId: 'tab-1',
+ agentSessionId: 'agent-2',
+ tabId: 'tab-9',
+ });
+ });
+
+ it('carries a provisional sentence count and an index past it, unclamped (C-26, C-27)', async () => {
+ world.session.emit({
+ type: 'speak-start',
+ utteranceId: 'utt-1',
+ sentenceCount: 2,
+ ttsProviderId: 'local',
+ streaming: true,
+ });
+ world.session.emit({ type: 'speak-sentence', utteranceId: 'utt-1', index: 4, text: 'Fourth.' });
+ await world.advance();
+
+ const [start] = voiceEventsFrom(device.receivedFrames(), 'speak-start');
+ const [sentence] = voiceEventsFrom(device.receivedFrames(), 'speak-sentence');
+ expect(start).toMatchObject({ sentenceCount: 2, streaming: true });
+ // The index is delivered as sent. Clamping it to `sentenceCount` here would
+ // hide the streaming case from every client at once.
+ expect(sentence).toMatchObject({ utteranceId: 'utt-1', index: 4 });
+ });
+
+ it('describes the DESKTOP microphone in mic-state and leaves the phone"s alone (C-28)', async () => {
+ world.session.emit({
+ type: 'mic-state',
+ permission: 'granted',
+ capturing: true,
+ deviceId: 'builtin',
+ deviceLabel: 'MacBook Pro Microphone',
+ issue: null,
+ deviceChanged: false,
+ });
+ await world.advance();
+
+ expect(voiceEventsFrom(device.receivedFrames(), 'mic-state')).toHaveLength(1);
+ // Nothing about the desktop's microphone opened this phone's.
+ expect(device.state().sending).toBe(false);
+ expect(device.client.microphone).toBeNull();
+ });
+
+ it('carries the egress statement verbatim (C-30)', async () => {
+ const egressStatement = 'Audio stays on this machine. Nothing is sent to a provider.';
+ world.session.emit({
+ type: 'provider-state',
+ pipeline: 'cascade',
+ slots: [
+ { role: 'stt', providerId: 'whisper-local', label: 'Whisper (local)', tier: 'local' },
+ ],
+ audioLeavesMachine: false,
+ egressStatement,
+ });
+ await world.advance();
+
+ const [state] = voiceEventsFrom(device.receivedFrames(), 'provider-state');
+ expect(state).toMatchObject({ audioLeavesMachine: false, egressStatement });
+ });
+});
+
+describe('conformance: the microphone is gated on the floor', () => {
+ it('captures nothing before the desktop grants the floor (C-37, C-38)', async () => {
+ // Pressed, but the desktop has not answered yet.
+ device.client.pressFloor();
+ expect(world.captured).toHaveLength(0);
+ expect(device.client.microphone).toBeNull();
+
+ await world.advance();
+ expect(device.state().floor.isSelf).toBe(true);
+ expect(device.client.microphone).not.toBeNull();
+ // And only now does a stream reach the desktop's capture pipeline.
+ expect(world.captured).toEqual([{ deviceId: device.deviceId }]);
+ });
+
+ it('stops capturing the moment the floor closes (C-37)', async () => {
+ await openFloor();
+ expect(device.state().sending).toBe(true);
+
+ world.session.emit({ type: 'listen-stop', reason: 'endpoint' });
+ await world.advance();
+
+ expect(device.state().floor.isSelf).toBe(false);
+ expect(device.state().sending).toBe(false);
+ expect(device.micTrack.stop).toHaveBeenCalled();
+ });
+
+ it('sends audio-level only while the floor is open, and throttled (C-39)', async () => {
+ device.client.reportAudioLevel(0.4, true);
+ await world.advance();
+ expect(
+ world.deviceMessages.filter((entry) => entry.message.type === 'audio-level')
+ ).toHaveLength(0);
+
+ await openFloor();
+ device.client.reportAudioLevel(0.4, true);
+ device.client.reportAudioLevel(0.5, true); // Same millisecond: throttled away.
+ await world.advance(60);
+ device.client.reportAudioLevel(0.6, false);
+ await world.advance();
+
+ const levels = world.deviceMessages
+ .map((entry) => entry.message)
+ .filter((message): message is Extract => {
+ return message.type === 'audio-level';
+ });
+ expect(levels).toHaveLength(2);
+ expect(levels[1]).toMatchObject({ level: 0.6, speech: false });
+ });
+
+ it('reports link quality from a throttled getStats (C-40)', async () => {
+ device.peer().statsReports = [
+ {
+ type: 'candidate-pair',
+ selected: true,
+ currentRoundTripTime: 0.031,
+ localCandidateId: 'L',
+ remoteCandidateId: 'R',
+ },
+ { type: 'local-candidate', id: 'L', candidateType: 'host' },
+ { type: 'remote-candidate', id: 'R', candidateType: 'host' },
+ { type: 'inbound-rtp', kind: 'audio', jitter: 0.004, packetsReceived: 99, packetsLost: 1 },
+ ];
+ await world.advance(2100);
+
+ const quality = world.deviceMessages
+ .map((entry) => entry.message)
+ .filter((message): message is Extract => {
+ return message.type === 'link-quality';
+ });
+ expect(quality).toHaveLength(1);
+ expect(quality[0]).toMatchObject({ rttMs: 31, candidateType: 'lan' });
+ // One measurement, one bar: the client shows what it sent.
+ expect(device.state().quality).toMatchObject({ rttMs: 31, candidateType: 'lan' });
+ });
+});
diff --git a/src/__tests__/acappella/conformance/failure-paths.conformance.test.ts b/src/__tests__/acappella/conformance/failure-paths.conformance.test.ts
new file mode 100644
index 0000000000..028e6636d7
--- /dev/null
+++ b/src/__tests__/acappella/conformance/failure-paths.conformance.test.ts
@@ -0,0 +1,452 @@
+/**
+ * Protocol conformance: the paths a phone actually hits, C-12 to C-15 and C-33
+ * to C-49.
+ *
+ * The happy path is the easy half. What breaks an iOS client in the field is a
+ * version bump on the desktop, a pairing revoked from another room, a train
+ * going into a tunnel, a second device grabbing the floor, and a stop word
+ * spoken while the assistant is mid-sentence. Every one of those runs here
+ * against the real desktop stack, because every one of them is a place where
+ * "the desktop just stops answering" is the symptom a client sees and cannot
+ * diagnose.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ DEVICE_PROTOCOL_VERSION,
+ MIN_SUPPORTED_DEVICE_PROTOCOL_VERSION,
+ RELIABLE_CHANNEL_LABEL,
+ type DeviceMessage,
+} from '../../../shared/acappella/device-protocol';
+import { createConformanceWorld, voiceEventsFrom, type ConformanceWorld } from './harness';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+vi.mock('../../../renderer/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let world: ConformanceWorld;
+
+beforeEach(async () => {
+ world = await createConformanceWorld();
+});
+
+afterEach(async () => {
+ await world.dispose();
+});
+
+/** Floor-state frames one device was sent, newest last. */
+function floorStates(
+ frames: DeviceMessage[]
+): Array> {
+ return frames.filter(
+ (frame): frame is Extract =>
+ frame.type === 'floor-state'
+ );
+}
+
+describe('conformance: version mismatch', () => {
+ it('refuses a version below the window before it looks at the credential (C-33)', async () => {
+ const raw = world.openRawDevice();
+ await raw.send({
+ op: 'auth',
+ deviceId: 'whoever',
+ token: 'whatever',
+ protocolVersion: MIN_SUPPORTED_DEVICE_PROTOCOL_VERSION - 1,
+ });
+
+ const [error] = raw.ofType('error');
+ expect(error.code).toBe('protocol-version');
+ // While the floor and the ceiling are both v1, everything below the floor
+ // is also below 1 and reads as unusable rather than as merely old. The
+ // `client-too-old` sentence itself is exercised through
+ // `negotiateProtocolVersion`'s range parameter in the device-protocol unit
+ // tests, which is what that parameter exists for.
+ expect(error.message).toBe('This device did not report a usable A Cappella protocol version.');
+ // The credential was never looked at: a client that cannot be talked to
+ // correctly is told THAT rather than authenticated into silence.
+ expect(raw.ofType('auth-failed')).toHaveLength(0);
+ expect(raw.ofType('authenticated')).toHaveLength(0);
+ });
+
+ it('refuses a client from above the window, and blames the desktop (C-33)', async () => {
+ const raw = world.openRawDevice();
+ await raw.send({
+ op: 'auth',
+ deviceId: 'whoever',
+ token: 'whatever',
+ protocolVersion: DEVICE_PROTOCOL_VERSION + 1,
+ });
+
+ expect(raw.ofType('error')[0].message).toContain('Update Maestro on the desktop');
+ });
+
+ it('treats a missing version as too old rather than as unversioned (C-06)', async () => {
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'auth', deviceId: 'whoever', token: 'whatever' });
+
+ expect(raw.ofType('error')[0].code).toBe('protocol-version');
+ });
+
+ it('is terminal at the client, keeps the Keychain item, and offers no retry (C-33, C-34)', async () => {
+ const device = await world.connectClient();
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'auth', deviceId: 'x', token: 'y', protocolVersion: 99 });
+ const rejection = raw.ofType('error')[0];
+
+ // The desktop's own frame, given to a real client.
+ device.socket().handlers.onMessage(rejection);
+ await world.advance();
+
+ const state = device.state();
+ expect(state.phase).toBe('terminal');
+ expect(state.canRetry).toBe(false);
+ expect(state.message).toBe(rejection.message);
+ // The pairing is still valid; only one end is behind. Deleting the token
+ // here would turn a five-minute update into a re-pair.
+ expect(device.store.value).not.toBeNull();
+ await world.advance(60_000);
+ expect(device.sockets).toHaveLength(1);
+ });
+});
+
+describe('conformance: a revoked pairing mid-session', () => {
+ it('ends everything the device was holding, and does not reconnect (C-12)', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(true);
+
+ await world.transport.revokeDevice(device.deviceId);
+ await world.advance();
+
+ expect(device.state().phase).toBe('terminal');
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(false);
+ // The floor it was holding is not left open for a timeout to notice.
+ expect(world.session.interrupt).toHaveBeenCalled();
+ expect(world.session.stopSession).toHaveBeenCalledWith('user');
+ await world.advance(60_000);
+ expect(device.sockets).toHaveLength(1);
+ });
+
+ it('sends revoked down a data channel that is still up, and the client forgets the token (C-12, C-13)', async () => {
+ const device = await world.connectClient();
+ // The socket died but the peer is healthy, which is the case where the
+ // device can still be told in words rather than by ICE noticing.
+ device.socket().drop();
+ await world.advance();
+
+ const revoked = device
+ .receivedFrames('reliable')
+ .filter((frame) => frame.type === 'revoked') as Array<
+ Extract
+ >;
+ expect(revoked).toHaveLength(1);
+ expect(revoked[0].message).toBeTruthy();
+ expect(device.state().phase).toBe('terminal');
+ expect(device.store.value).toBeNull();
+ });
+});
+
+describe('conformance: the Encore Feature switched off mid-session', () => {
+ it('drops the connection a phone was holding, without forgetting the phone', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(true);
+
+ // Somebody at the keyboard unticks the box. Everything the phone was
+ // holding has to go with it, or the switch is a lie.
+ world.setACappellaEnabled(false);
+ world.transport.standDown();
+ await world.advance();
+
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(false);
+ expect(world.transport.discoveryStatus()).toEqual({ state: 'disabled' });
+
+ // But the pairing survives. "Stop" is not "forget my phone", and a device
+ // that had to be re-paired because a checkbox was toggled would teach people
+ // not to touch the checkbox.
+ const devices = await world.transport.listDevices();
+ expect(devices.map((entry) => entry.id)).toContain(device.deviceId);
+ expect(devices.find((entry) => entry.id === device.deviceId)?.revokedAt).toBeNull();
+ });
+
+ it('refuses a reconnect while the feature is off, in a sentence a phone can show', async () => {
+ const device = await world.connectClient();
+ world.setACappellaEnabled(false);
+ world.transport.standDown();
+ await world.advance();
+
+ // A phone whose socket died retries. The desktop that answers has to say
+ // which of "switched off" and "the network ate it" this is, because only one
+ // of them is worth retrying.
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'auth', deviceId: device.deviceId, token: 'anything', v: 1 });
+
+ expect(raw.ofType('error')).toHaveLength(1);
+ expect(raw.ofType('error')[0].message).toMatch(/Encore Features/);
+ });
+
+ it('serves the same device again once the feature comes back on', async () => {
+ world.setACappellaEnabled(false);
+ world.transport.standDown();
+ await world.advance();
+ world.setACappellaEnabled(true);
+
+ // The transport was stood down, not disposed, so this needs no restart.
+ const device = await world.connectClient();
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(true);
+ });
+});
+
+describe('conformance: a network drop and a reconnect', () => {
+ it('re-authenticates on a new socket and starts the floor closed (C-09, C-49)', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ expect(device.state().floor.isSelf).toBe(true);
+
+ device.dropNetwork();
+ await world.advance();
+ // The desktop does not wait for a timeout to end a session whose microphone
+ // walked away.
+ expect(world.session.stopSession).toHaveBeenCalledWith('user');
+ expect(device.state().floor.isSelf).toBe(false);
+
+ // The backoff starts at a second, and a reconnect is a fresh `auth` on a
+ // fresh socket: an authenticated state is never inherited.
+ await world.advance(1200);
+ await world.advance(200);
+ expect(device.sockets.length).toBeGreaterThan(1);
+ expect(device.socket().ops()).toContain('auth');
+ expect(device.socket().ops()).not.toContain('pair-claim');
+ expect(device.state().phase).toBe('connected');
+ expect(device.state().floor).toEqual({ holder: null, isSelf: false });
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(true);
+ });
+
+ it('speaks the protocol again on the new connection (C-19)', async () => {
+ const device = await world.connectClient();
+ device.dropNetwork();
+ await world.advance(1400);
+
+ // A new peer, a new pair of channels, and `hello` first on the state one.
+ expect(device.peers.length).toBeGreaterThan(1);
+ const [first] = device.sentFrames('reliable');
+ expect(first).toMatchObject({ type: 'hello', identity: { deviceId: device.deviceId } });
+
+ device.client.pressFloor();
+ await world.advance();
+ expect(device.state().floor.isSelf).toBe(true);
+ });
+});
+
+describe('conformance: two devices, one floor', () => {
+ it('gives the floor to the device that pressed last and tells the other who took it (C-48)', async () => {
+ const phone = await world.connectClient({ name: 'Phone A' });
+ const tablet = await world.connectClient({ name: 'Phone B' });
+
+ phone.client.pressFloor();
+ await world.advance();
+ expect(phone.state().floor.isSelf).toBe(true);
+
+ tablet.client.pressFloor();
+ await world.advance();
+
+ expect(tablet.state().floor.isSelf).toBe(true);
+ expect(phone.state().floor.isSelf).toBe(false);
+ // The displaced device was told BEFORE the new session started, so its
+ // button lets go while the takeover happens rather than after.
+ //
+ // The notice is momentary by design: `takenOverBy` rides its own frame and
+ // the ordinary `floor-state` broadcast that follows carries no name, so a
+ // client must react to the frame rather than render the field out of its
+ // stored state.
+ const displaced = floorStates(phone.receivedFrames('reliable'));
+ expect(displaced.some((frame) => frame.takenOverBy === 'Phone B')).toBe(true);
+ expect(displaced[displaced.length - 1]).toMatchObject({ holder: tablet.deviceId });
+ });
+
+ it('ignores the stale release the displaced device sends a moment later', async () => {
+ const phone = await world.connectClient({ name: 'Phone A' });
+ const tablet = await world.connectClient({ name: 'Phone B' });
+ phone.client.pressFloor();
+ await world.advance();
+ tablet.client.pressFloor();
+ await world.advance();
+
+ phone.client.releaseFloor();
+ await world.advance();
+
+ // Acting on it would shut the microphone of the device that just took the
+ // floor.
+ expect(world.floor.releases).toHaveLength(0);
+ expect(tablet.state().floor.isSelf).toBe(true);
+ });
+
+ it('refuses an interrupt from a device that is not holding the floor', async () => {
+ const phone = await world.connectClient({ name: 'Phone A' });
+ const tablet = await world.connectClient({ name: 'Phone B' });
+ phone.client.pressFloor();
+ await world.advance();
+
+ tablet.client.requestBargeIn();
+ tablet.client.requestStop();
+ await world.advance();
+
+ expect(world.session.interrupt).not.toHaveBeenCalled();
+ expect(world.session.hardStop).not.toHaveBeenCalled();
+ });
+
+ it('shows every device the whole session, whoever holds the microphone', async () => {
+ const phone = await world.connectClient({ name: 'Phone A' });
+ const tablet = await world.connectClient({ name: 'Phone B' });
+ phone.client.pressFloor();
+ await world.advance();
+
+ world.session.emit({ type: 'final-transcript', text: 'open the auth tab' });
+ await world.advance();
+
+ // Only the microphone is exclusive. A phone in a pocket still has to be
+ // able to show what the Mac is doing.
+ expect(voiceEventsFrom(tablet.receivedFrames(), 'final-transcript')).toHaveLength(1);
+ expect(voiceEventsFrom(phone.receivedFrames(), 'final-transcript')).toHaveLength(1);
+ });
+});
+
+describe('conformance: a stop word while TTS is streaming', () => {
+ it('ends the session, releases the floor, and closes the microphone (C-42, C-46)', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ world.session.emit({
+ type: 'speak-start',
+ utteranceId: 'utt-1',
+ sentenceCount: 1,
+ ttsProviderId: 'local',
+ streaming: true,
+ });
+ world.session.emit({
+ type: 'speak-sentence',
+ utteranceId: 'utt-1',
+ index: 0,
+ text: 'The auth',
+ });
+ await world.advance();
+ expect(device.state().sending).toBe(true);
+
+ device.client.requestStop();
+ await world.advance();
+
+ // A stop word is a hard stop, never an interrupt: the difference is whether
+ // the user can get rid of the assistant.
+ expect(world.session.hardStop).toHaveBeenCalledWith('client-button');
+ expect(world.session.interrupt).not.toHaveBeenCalled();
+
+ // The session ending is what releases the floor, so the device holds it
+ // until the desktop says otherwise.
+ world.session.emit({ type: 'speak-end', utteranceId: 'utt-1', reason: 'cancelled' });
+ world.session.emit({ type: 'stop-word', source: 'voice', phrase: 'maestro stop' });
+ await world.advance();
+
+ expect(device.state().floor).toEqual({ holder: null, isSelf: false });
+ expect(device.state().sending).toBe(false);
+ expect(device.micTrack.stop).toHaveBeenCalled();
+ });
+
+ it('ducks locally before the frame goes out and lifts on the desktop"s answer (C-44, C-45)', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ world.session.emit({
+ type: 'speak-start',
+ utteranceId: 'utt-2',
+ sentenceCount: 2,
+ ttsProviderId: 'local',
+ });
+ await world.advance();
+ device.events.length = 0;
+
+ device.client.requestBargeIn();
+ await world.advance();
+
+ // The duck happened locally, in the same turn, before the interrupt could
+ // possibly have reached the desktop.
+ expect(device.events[0]).toEqual({ type: 'duck', ducked: true });
+ expect(world.session.interrupt).toHaveBeenCalledWith('client-button');
+ // Barge-in KEEPS the floor.
+ expect(device.state().floor.isSelf).toBe(true);
+
+ world.session.emit({
+ type: 'barge-in',
+ source: 'client-button',
+ cancelledUtteranceId: 'utt-2',
+ });
+ await world.advance();
+ expect(device.events).toContainEqual({ type: 'duck', ducked: false });
+ expect(device.state().floor.isSelf).toBe(true);
+ });
+
+ it('lifts a duck the desktop never answers, and keeps the floor (C-45)', async () => {
+ const device = await world.connectClient();
+ device.client.pressFloor();
+ await world.advance();
+ device.events.length = 0;
+
+ device.client.requestBargeIn();
+ await world.advance(600);
+
+ expect(device.events).toContainEqual({ type: 'duck', ducked: false });
+ expect(device.state().floor.isSelf).toBe(true);
+ });
+});
+
+describe('conformance: a wake word is an ordinary press', () => {
+ it('opens the same floor a hotkey opens, with the scope the wheel selected (C-41)', async () => {
+ const device = await world.connectClient();
+ // What a wake-word hit produces on the device: a plain `floor: press`, not
+ // a `wake` event and not a message type of its own.
+ device.client.pressFloor({ kind: 'agent', sessionId: 'agent-7' });
+ await world.advance();
+
+ expect(world.floor.presses).toHaveLength(1);
+ expect(world.floor.presses[0]).toEqual({
+ scope: { kind: 'agent', sessionId: 'agent-7' },
+ origin: { kind: 'remote', deviceId: device.deviceId, deviceName: device.name },
+ });
+ expect(world.floor.press).toHaveBeenCalledWith('remote-device');
+ // And the desktop routes the phone's microphone into the one capture
+ // pipeline rather than a second one.
+ expect(world.captured).toEqual([{ deviceId: device.deviceId }]);
+ expect(device.sentFrames('unreliable')[0]).toMatchObject({
+ type: 'floor',
+ action: 'press',
+ scope: { kind: 'agent', sessionId: 'agent-7' },
+ });
+ });
+
+ it('never lets a device-sent voice-event drive the session', async () => {
+ const device = await world.connectClient();
+ // The Phase 01 protocol marks `wake` as client-originable, but not over
+ // this transport: the device channel expresses it as `floor`.
+ device
+ .peer()
+ .channel(RELIABLE_CHANNEL_LABEL)
+ .send(
+ JSON.stringify({
+ type: 'voice-event',
+ v: DEVICE_PROTOCOL_VERSION,
+ event: { type: 'wake', sessionId: 's', seq: 1, ts: 0, scope: { kind: 'conductor' } },
+ })
+ );
+ await world.advance();
+
+ // It decodes, and then goes nowhere: no floor, no session.
+ expect(world.floor.presses).toHaveLength(0);
+ expect(world.hostCommands.some((command) => command.kind === 'set-floor-holder')).toBe(false);
+ });
+});
diff --git a/src/__tests__/acappella/conformance/harness.ts b/src/__tests__/acappella/conformance/harness.ts
new file mode 100644
index 0000000000..4711e949c0
--- /dev/null
+++ b/src/__tests__/acappella/conformance/harness.ts
@@ -0,0 +1,852 @@
+/**
+ * A desktop, a network, and a phone, in one process.
+ *
+ * This is the harness the protocol conformance suite runs on. It assembles the
+ * REAL desktop stack - `ACappellaTransport` (pairing, signaling, the remote
+ * session coordinator) wired to a real `PeerRegistry` through the same
+ * `applyWebRtcCommand` switch the hidden audio window uses - and drives it with
+ * the REAL browser reference client from `src/web-desktop/acappella-client/`.
+ * Nothing between them is stubbed except the two things a test cannot have: a
+ * socket and libwebrtc.
+ *
+ * That is the whole point. A conformance suite that mocked either end would
+ * assert that a mock agrees with itself. Here a message leaves
+ * `ACappellaReferenceClient.send()`, is JSON, crosses a loopback data channel,
+ * and is decoded by `decodeDeviceMessage()` inside the desktop's `DevicePeer` -
+ * so a desktop-side change that would break an iOS client fails here rather
+ * than at App Store review.
+ *
+ * What is faked, and how faithfully:
+ *
+ * - **The WebSocket** ({@link LoopbackSocket}) carries the real
+ * `{type:'acappella_signal', payload}` envelope over the real `/$TOKEN/ws`
+ * URL shape, mirroring the two lines in
+ * `main/web-server/handlers/messageHandlers/acappellaSignal.ts`. `onClose`
+ * fires in a microtask, as a browser's does, because firing it
+ * synchronously inside `close()` would let a teardown reconnect to itself.
+ * - **The peer connection** ({@link LoopbackPeer}) mirrors data channels
+ * between the two ends, delivers a replaced track to the far side's
+ * `ontrack`, and answers `getStats()`. A channel closed by one end closes
+ * on the other, which is what makes "the desktop closes a label it does not
+ * recognise" observable from the client.
+ *
+ * Timers are faked by {@link createConformanceWorld} and released by
+ * `dispose()`, because the client's pairing poll is a 1 s interval and its
+ * reconnect backoff starts at 1 s.
+ */
+
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+import { vi, type Mock } from 'vitest';
+
+import { ACappellaTransport } from '../../../main/acappella/transport';
+import type {
+ RemoteFloor,
+ RemoteVoiceSession,
+} from '../../../main/acappella/transport/remote-session';
+import {
+ PeerRegistry,
+ applyWebRtcCommand,
+ type PeerAudioBinding,
+} from '../../../renderer/acappella-audio/peer-connection';
+import {
+ decodeDeviceMessage,
+ type DeviceChannelKind,
+ type DeviceMessage,
+ RELIABLE_CHANNEL_LABEL,
+ UNRELIABLE_CHANNEL_LABEL,
+} from '../../../shared/acappella/device-protocol';
+import type { VoiceEvent, VoiceOrigin, VoiceScope } from '../../../shared/acappella/protocol';
+import {
+ ACAPPELLA_SIGNAL_MESSAGE,
+ type SignalingClientMessage,
+ type SignalingServerMessage,
+} from '../../../shared/acappella/signaling-protocol';
+import type {
+ IceCandidatePayload,
+ WebRtcHostCommand,
+ WebRtcHostEvent,
+} from '../../../shared/acappella/webrtc-host';
+import {
+ ACappellaReferenceClient,
+ PAIR_POLL_INTERVAL_MS,
+ type ClientEvent,
+ type ClientState,
+ type PairingStore,
+ type SignalingSocket,
+ type SignalingSocketHandlers,
+ type StoredPairing,
+} from '../../../web-desktop/acappella-client/client';
+
+/** The web server's security token, which is what gets a frame looked at at all. */
+export const SERVER_TOKEN = 'conformance-server-token';
+export const SERVER_PORT = 4123;
+export const SERVER_HOST = '192.168.1.5';
+export const DESKTOP_APP_VERSION = '1.2.3';
+
+/**
+ * An SDP with one Opus m-line, which is all `applyOpusPreferences()` reads.
+ *
+ * Deliberately carries `useinbandfec=0` so an assertion that the tuning was
+ * applied cannot pass by accident on an SDP that already said the right thing.
+ */
+export const OPUS_SDP = [
+ 'v=0',
+ 'o=- 0 0 IN IP4 127.0.0.1',
+ 's=-',
+ 'm=audio 9 UDP/TLS/RTP/SAVPF 111',
+ 'a=rtpmap:111 opus/48000/2',
+ 'a=fmtp:111 minptime=10;useinbandfec=0',
+ '',
+].join('\r\n');
+
+// ---------------------------------------------------------------------------
+// The loopback peer connection
+// ---------------------------------------------------------------------------
+
+export class LoopbackChannel {
+ readyState: RTCDataChannelState = 'connecting';
+ onopen: (() => void) | null = null;
+ onmessage: ((event: { data: string }) => void) | null = null;
+ onclose: (() => void) | null = null;
+ /** Every frame this end put on the wire, as sent. */
+ readonly sent: string[] = [];
+ /** The channel at the other end of the SCTP stream. */
+ remote: LoopbackChannel | null = null;
+
+ constructor(
+ readonly label: string,
+ readonly init: RTCDataChannelInit | undefined
+ ) {}
+
+ open(): void {
+ if (this.readyState !== 'connecting') return;
+ this.readyState = 'open';
+ this.onopen?.();
+ }
+
+ send(data: string): void {
+ this.sent.push(data);
+ const remote = this.remote;
+ if (!remote || remote.readyState !== 'open') return;
+ remote.onmessage?.({ data });
+ }
+
+ close(): void {
+ if (this.readyState === 'closed') return;
+ this.readyState = 'closed';
+ const remote = this.remote;
+ this.remote = null;
+ this.onclose?.();
+ if (remote) {
+ remote.remote = null;
+ remote.close();
+ }
+ }
+
+ /** Everything this end sent, decoded through the real protocol decoder. */
+ messages(): DeviceMessage[] {
+ return this.sent
+ .map((raw) => decodeDeviceMessage(raw))
+ .filter((message): message is DeviceMessage => message !== null);
+ }
+
+ /** Everything this end sent, as raw objects, so a missing `v` is visible. */
+ frames(): Array> {
+ return this.sent.map((raw) => JSON.parse(raw) as Record);
+ }
+}
+
+interface LoopbackSender {
+ track: MediaStreamTrack | null;
+ replaceTrack(track: MediaStreamTrack | null): Promise;
+ getParameters(): { encodings?: Array> };
+ setParameters(parameters: unknown): Promise;
+}
+
+/** Enough `RTCPeerConnection` for both ends of this protocol, and no more. */
+export class LoopbackPeer {
+ connectionState: RTCPeerConnectionState = 'new';
+ onicecandidate: ((event: { candidate: RTCIceCandidate | null }) => void) | null = null;
+ ontrack: ((event: { streams: MediaStream[]; track: MediaStreamTrack }) => void) | null = null;
+ onconnectionstatechange: (() => void) | null = null;
+ ondatachannel: ((event: { channel: LoopbackChannel }) => void) | null = null;
+
+ readonly channels: LoopbackChannel[] = [];
+ readonly senders: LoopbackSender[] = [];
+ readonly addedTracks: MediaStreamTrack[] = [];
+ readonly candidates: IceCandidatePayload[] = [];
+ /** What `getStats()` answers. Tests fill this in when they care. */
+ statsReports: Array> = [];
+ localDescription: { type: string; sdp?: string } | null = null;
+ remoteDescription: { type: string; sdp?: string } | null = null;
+ closed = false;
+ remote: LoopbackPeer | null = null;
+
+ constructor(
+ readonly config: RTCConfiguration,
+ readonly role: 'client' | 'desktop'
+ ) {}
+
+ createDataChannel(label: string, init?: RTCDataChannelInit): LoopbackChannel {
+ const channel = new LoopbackChannel(label, init);
+ this.channels.push(channel);
+ // A channel created after the peers were linked still has to reach the far
+ // end, which is how the "unrecognised label" path is exercised.
+ if (this.remote) this.remote.acceptChannel(channel);
+ return channel;
+ }
+
+ /** Mirror a channel the far end created, and open both halves. */
+ acceptChannel(theirs: LoopbackChannel): void {
+ const mirror = new LoopbackChannel(theirs.label, theirs.init);
+ mirror.remote = theirs;
+ theirs.remote = mirror;
+ this.channels.push(mirror);
+ this.ondatachannel?.({ channel: mirror });
+ // The far end may have closed it on sight, and a closed channel must not be
+ // reopened by the link step.
+ if (mirror.readyState === 'closed' || theirs.readyState === 'closed') return;
+ mirror.open();
+ theirs.open();
+ }
+
+ addTransceiver(_kind: string, _init: { direction: string }): { sender: LoopbackSender } {
+ return { sender: this.createSender() };
+ }
+
+ addTrack(track: MediaStreamTrack): LoopbackSender {
+ this.addedTracks.push(track);
+ const sender = this.createSender();
+ void sender.replaceTrack(track);
+ return sender;
+ }
+
+ getSenders(): LoopbackSender[] {
+ return this.senders;
+ }
+
+ createOffer(_options?: { iceRestart?: boolean }): Promise<{ type: string; sdp: string }> {
+ return Promise.resolve({ type: 'offer', sdp: OPUS_SDP });
+ }
+
+ createAnswer(): Promise<{ type: string; sdp: string }> {
+ // Echo whatever was offered, so an assertion about the answer's tuning is
+ // about the desktop's shaping rather than about this fake's constant.
+ return Promise.resolve({ type: 'answer', sdp: this.remoteDescription?.sdp ?? OPUS_SDP });
+ }
+
+ setLocalDescription(description: { type: string; sdp?: string }): Promise {
+ this.localDescription = description;
+ return Promise.resolve();
+ }
+
+ setRemoteDescription(description: { type: string; sdp?: string }): Promise {
+ this.remoteDescription = description;
+ return Promise.resolve();
+ }
+
+ addIceCandidate(candidate: IceCandidatePayload): Promise {
+ this.candidates.push(candidate);
+ return Promise.resolve();
+ }
+
+ getStats(): Promise<{ forEach: (fn: (value: unknown) => void) => void }> {
+ const reports = this.statsReports;
+ return Promise.resolve({ forEach: (fn) => reports.forEach(fn) });
+ }
+
+ close(): void {
+ if (this.closed) return;
+ this.closed = true;
+ for (const channel of this.channels) channel.close();
+ }
+
+ /** One gathered candidate, trickled out the way ICE does. */
+ gatherCandidate(candidate: string): void {
+ this.onicecandidate?.({
+ candidate: {
+ candidate,
+ sdpMid: '0',
+ sdpMLineIndex: 0,
+ usernameFragment: 'ufrag',
+ } as unknown as RTCIceCandidate,
+ });
+ }
+
+ setConnectionState(state: RTCPeerConnectionState): void {
+ this.connectionState = state;
+ this.onconnectionstatechange?.();
+ }
+
+ channel(label: string): LoopbackChannel {
+ const found = this.channels.find((entry) => entry.label === label);
+ if (!found) throw new Error(`No channel '${label}' on the ${this.role} peer`);
+ return found;
+ }
+
+ private createSender(): LoopbackSender {
+ const peer = this;
+ const sender: LoopbackSender = {
+ track: null,
+ replaceTrack(track: MediaStreamTrack | null): Promise {
+ sender.track = track;
+ // A track attached at this end arrives at the other end's `ontrack`,
+ // which is what makes "nothing before the floor opens" observable from
+ // the desktop rather than only from the client.
+ if (track) peer.remote?.deliverTrack(track);
+ return Promise.resolve();
+ },
+ getParameters: () => ({ encodings: [{}] }),
+ setParameters: () => Promise.resolve(),
+ };
+ this.senders.push(sender);
+ return sender;
+ }
+
+ private deliverTrack(track: MediaStreamTrack): void {
+ const stream = {
+ id: `stream-${this.role}`,
+ getTracks: () => [track],
+ } as unknown as MediaStream;
+ this.ontrack?.({ streams: [stream], track });
+ }
+}
+
+/** Join two peers and open every channel either of them has already created. */
+export function linkPeers(client: LoopbackPeer, desktop: LoopbackPeer): void {
+ client.remote = desktop;
+ desktop.remote = client;
+ for (const channel of [...client.channels]) desktop.acceptChannel(channel);
+}
+
+// ---------------------------------------------------------------------------
+// The loopback socket
+// ---------------------------------------------------------------------------
+
+/** One `acappella_signal` envelope, as it would appear on the WebSocket. */
+export interface SignalEnvelope {
+ type: string;
+ payload: SignalingClientMessage | SignalingServerMessage;
+}
+
+export class LoopbackSocket implements SignalingSocket {
+ /** Envelopes this socket put on the wire, outermost shape included. C-01. */
+ readonly outbound: SignalEnvelope[] = [];
+ readonly inbound: SignalingServerMessage[] = [];
+ closed = false;
+
+ constructor(
+ readonly clientId: string,
+ readonly url: string,
+ readonly handlers: SignalingSocketHandlers,
+ private readonly deliver: (payload: unknown) => void,
+ private readonly onGone: () => void
+ ) {}
+
+ send(message: SignalingClientMessage): void {
+ if (this.closed) return;
+ const envelope: SignalEnvelope = { type: ACAPPELLA_SIGNAL_MESSAGE, payload: message };
+ this.outbound.push(envelope);
+ // The web-server handler unwraps `payload` and hands it over untouched.
+ this.deliver(envelope.payload);
+ }
+
+ /** The desktop wrote back. */
+ receive(message: SignalingServerMessage): void {
+ if (this.closed) return;
+ this.inbound.push(message);
+ this.handlers.onMessage(message);
+ }
+
+ close(): void {
+ this.drop();
+ }
+
+ /** The socket went away, for any reason. */
+ drop(): void {
+ if (this.closed) return;
+ this.closed = true;
+ this.onGone();
+ // A browser fires `close` in a later task. Firing it inline would run the
+ // client's reconnect logic in the middle of its own teardown.
+ queueMicrotask(() => this.handlers.onClose());
+ }
+
+ ops(): string[] {
+ return this.outbound.map((envelope) => String((envelope.payload as { op: string }).op));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Desktop-side fakes
+// ---------------------------------------------------------------------------
+
+/** The voice session, reduced to what a remote device can observe and drive. */
+export interface FakeVoiceSession extends RemoteVoiceSession {
+ emit(event: Partial & { type: VoiceEvent['type'] }): void;
+ interrupt: Mock;
+ hardStop: Mock;
+ stopSession: Mock;
+ /** Session id and monotonic seq, so a broadcast stream is contiguous. */
+ sessionId: string;
+ seq: number;
+}
+
+export interface FakeFloor extends RemoteFloor {
+ readonly presses: Array<{ scope: VoiceScope; origin: VoiceOrigin }>;
+ readonly releases: string[];
+}
+
+function createFakeSession(): FakeVoiceSession {
+ let listeners: Array<(event: VoiceEvent) => void> = [];
+ const session = {
+ sessionId: 'voice-1',
+ seq: 0,
+ subscribe(listener: (event: VoiceEvent) => void) {
+ listeners.push(listener);
+ return () => {
+ listeners = listeners.filter((entry) => entry !== listener);
+ };
+ },
+ interrupt: vi.fn(() => true),
+ hardStop: vi.fn(async () => {}),
+ stopSession: vi.fn(async () => {}),
+ getState: () => 'listening',
+ emit(event: Partial & { type: VoiceEvent['type'] }) {
+ session.seq += 1;
+ const full = {
+ sessionId: session.sessionId,
+ seq: session.seq,
+ ts: 0,
+ ...event,
+ } as VoiceEvent;
+ for (const listener of [...listeners]) listener(full);
+ },
+ } as unknown as FakeVoiceSession;
+ return session;
+}
+
+// ---------------------------------------------------------------------------
+// The world
+// ---------------------------------------------------------------------------
+
+export interface ConformanceClient {
+ readonly client: ACappellaReferenceClient;
+ readonly deviceId: string;
+ readonly name: string;
+ readonly store: PairingStore & { value: StoredPairing | null };
+ readonly events: ClientEvent[];
+ readonly sockets: LoopbackSocket[];
+ readonly peers: LoopbackPeer[];
+ /** The live signaling socket. */
+ socket(): LoopbackSocket;
+ /** The client's end of the peer connection. */
+ peer(): LoopbackPeer;
+ /** The desktop's end of the same connection. */
+ desktopPeer(): LoopbackPeer;
+ state(): ClientState;
+ /** Frames this client put on the wire, on one channel or both. */
+ sentFrames(kind?: DeviceChannelKind): DeviceMessage[];
+ /** Frames the desktop sent to this client, on one channel or both. */
+ receivedFrames(kind?: DeviceChannelKind): DeviceMessage[];
+ /** Raw objects this client sent, so a missing `v` is visible. */
+ rawSentFrames(): Array>;
+ /** The network went away: media dead, socket dead. */
+ dropNetwork(): void;
+ /** The microphone stream `openMicrophone()` hands out. */
+ readonly micTrack: MediaStreamTrack;
+}
+
+/** A device with no client behind it, for the frames a conforming client never sends. */
+export interface RawDevice {
+ socket: LoopbackSocket;
+ send(message: SignalingClientMessage | Record): Promise;
+ received: SignalingServerMessage[];
+ last(): SignalingServerMessage | undefined;
+ ofType(
+ op: T
+ ): Array>;
+}
+
+export interface ConformanceWorld {
+ readonly transport: ACappellaTransport;
+ readonly peers: PeerRegistry;
+ readonly session: FakeVoiceSession;
+ readonly floor: FakeFloor;
+ /** Every command the transport sent to the audio host, in order. */
+ readonly hostCommands: WebRtcHostCommand[];
+ /**
+ * Every frame the desktop actually DECODED off a data channel.
+ *
+ * The proof that a client's message round-tripped: it left the client as a
+ * string, crossed the channel, and came back out of `decodeDeviceMessage()`
+ * inside the desktop's own peer. A frame the desktop dropped never appears
+ * here, and dropping is silent by design.
+ */
+ readonly deviceMessages: Array<{ deviceId: string; message: DeviceMessage }>;
+ /** Streams the desktop routed into the capture pipeline, and when. */
+ readonly captured: Array<{ deviceId: string }>;
+ readonly detached: string[];
+ /** Pair, authenticate, and connect one reference client. */
+ connectClient(options?: { name?: string; platform?: string }): Promise;
+ /** A socket that speaks signaling by hand, with no client behind it. */
+ openRawDevice(): RawDevice;
+ /**
+ * Flip the Encore Feature. Off is a state a CONNECTED phone can be put into by
+ * somebody at the keyboard, so it is a wire behaviour and not just a settings
+ * concern.
+ */
+ setACappellaEnabled(enabled: boolean): void;
+ /** Run every pending microtask and timer up to `ms`. */
+ advance(ms?: number): Promise;
+ dispose(): Promise;
+}
+
+export async function createConformanceWorld(): Promise {
+ const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-conformance-'));
+ vi.useFakeTimers();
+
+ const session = createFakeSession();
+ const presses: Array<{ scope: VoiceScope; origin: VoiceOrigin }> = [];
+ const releases: string[] = [];
+ const floor = {
+ presses,
+ releases,
+ press: vi.fn(async (source?: string) => {
+ void source;
+ }),
+ release: vi.fn(async (source?: string) => {
+ releases.push(source ?? 'unknown');
+ }),
+ close: vi.fn(async () => {}),
+ isFloorOpen: false,
+ } as unknown as FakeFloor;
+
+ const captured: Array<{ deviceId: string }> = [];
+ const detached: string[] = [];
+ const audio: PeerAudioBinding = {
+ attachRemoteStream: (_stream, deviceId) => captured.push({ deviceId }),
+ detachRemoteStream: (deviceId) => detached.push(deviceId),
+ getOutboundTrack: () => ({ kind: 'audio', id: 'assistant-voice' }) as MediaStreamTrack,
+ };
+
+ const clientsByDevice = new Map();
+ const desktopPeers = new Map();
+ const hostCommands: WebRtcHostCommand[] = [];
+ const deviceMessages: Array<{ deviceId: string; message: DeviceMessage }> = [];
+ let pendingDeviceId: string | null = null;
+ let transport!: ACappellaTransport;
+
+ const peers = new PeerRegistry({
+ audio,
+ callbacks: {
+ onAnswer: (deviceId, answer) =>
+ transport.handleHostEvent({ kind: 'answer', deviceId, answer }),
+ onIceCandidate: (deviceId, candidate) =>
+ transport.handleHostEvent({ kind: 'ice-candidate', deviceId, candidate }),
+ onConnectionState: (deviceId, state) =>
+ transport.handleHostEvent({ kind: 'connection-state', deviceId, state }),
+ onStats: (stats) => transport.handleHostEvent({ kind: 'stats', stats }),
+ onMessage: (deviceId, message) => {
+ deviceMessages.push({ deviceId, message });
+ transport.handleHostEvent({ kind: 'message', deviceId, message });
+ },
+ onError: (deviceId, message) =>
+ transport.handleHostEvent({ kind: 'peer-error', deviceId, message }),
+ },
+ createPeerConnection: (config) => {
+ const peer = new LoopbackPeer(config, 'desktop');
+ const deviceId = pendingDeviceId;
+ pendingDeviceId = null;
+ if (deviceId) {
+ desktopPeers.set(deviceId, peer);
+ // Linked a microtask later: the `DevicePeer` constructor has not run
+ // yet, so `ondatachannel` is not bound at the moment this factory is
+ // called and mirroring here would drop both channels on the floor.
+ queueMicrotask(() => {
+ const client = clientsByDevice.get(deviceId);
+ if (!client) return;
+ linkPeers(client.peer(), peer);
+ peer.setConnectionState('connected');
+ client.peer().setConnectionState('connected');
+ });
+ }
+ return peer as unknown as RTCPeerConnection;
+ },
+ // Long enough that the desktop's own stats poll never fires by surprise;
+ // tests that care drive `getStats()` by advancing deliberately.
+ statsIntervalMs: 10 * 60_000,
+ });
+
+ /**
+ * Settings, mutable, with the Encore Feature on.
+ *
+ * On because a conformance run is by definition a run with A Cappella
+ * enabled, and mutable because switching it off is itself a failure path a
+ * connected phone will hit.
+ */
+ const settings: Record = { encoreFeatures: { aCappella: true } };
+
+ transport = new ACappellaTransport({
+ settingsStore: {
+ get: (key: string, defaultValue?: unknown) => settings[key] ?? defaultValue ?? {},
+ },
+ userDataPath,
+ sendToAudioHost: (command: WebRtcHostCommand) => {
+ hostCommands.push(command);
+ if (command.kind === 'accept-offer') pendingDeviceId = command.deviceId;
+ applyWebRtcCommand(peers, command, (event: WebRtcHostEvent) =>
+ transport.handleHostEvent(event)
+ );
+ },
+ acquireFloor: (scope: VoiceScope, origin?: VoiceOrigin) => {
+ presses.push({ scope, origin: origin ?? { kind: 'local' } });
+ return floor;
+ },
+ getSession: () => session,
+ getServerToken: () => SERVER_TOKEN,
+ getServerPort: () => SERVER_PORT,
+ getAppVersion: () => DESKTOP_APP_VERSION,
+ getMachineName: () => 'Conformance Desktop',
+ });
+
+ let socketSeq = 0;
+ function openSocket(url: string, handlers: SignalingSocketHandlers): LoopbackSocket {
+ socketSeq += 1;
+ const clientId = `socket-${socketSeq}`;
+ const socket: LoopbackSocket = new LoopbackSocket(
+ clientId,
+ url,
+ handlers,
+ (payload) => {
+ // The Encore gate, exactly as `acappellaSignal.ts` applies it: the
+ // transport outlives the flag on purpose, so its existence is not
+ // permission to serve.
+ if (!transport.featureEnabled()) {
+ socket.receive({
+ op: 'error',
+ code: 'not-authenticated',
+ message: 'A Cappella is not running on this desktop. Turn it on in Encore Features.',
+ });
+ return;
+ }
+ // Lazy, idempotent registration on the first message, exactly as the
+ // WebSocket route does it.
+ transport.registerClient({
+ clientId,
+ send: (message: SignalingServerMessage) => socket.receive(message),
+ remoteAddress: '192.168.1.44',
+ });
+ void transport.handleSignalMessage(clientId, payload);
+ },
+ () => transport.handleClientDisconnect(clientId)
+ );
+ queueMicrotask(() => {
+ if (!socket.closed) handlers.onOpen();
+ });
+ return socket;
+ }
+
+ async function advance(ms = 0): Promise {
+ await vi.advanceTimersByTimeAsync(ms);
+ // A couple of extra turns for the promise chains the client runs between
+ // `authenticated` and its first offer.
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
+ }
+
+ async function connectClient(
+ options: { name?: string; platform?: string } = {}
+ ): Promise {
+ const name = options.name ?? `Reference ${clientsByDevice.size + 1}`;
+ const platform = options.platform ?? 'ios';
+ const offer = transport.startPairing();
+ if (!offer) throw new Error('The desktop refused to open a pairing window');
+
+ const sockets: LoopbackSocket[] = [];
+ const clientPeers: LoopbackPeer[] = [];
+ const events: ClientEvent[] = [];
+ const micTrack = { kind: 'audio', id: 'mic', stop: vi.fn() } as unknown as MediaStreamTrack;
+ const store: PairingStore & { value: StoredPairing | null } = {
+ value: null,
+ read() {
+ return this.value;
+ },
+ write(pairing: StoredPairing) {
+ this.value = pairing;
+ },
+ clear() {
+ this.value = null;
+ },
+ };
+
+ const client = new ACappellaReferenceClient({
+ identity: { name, platform, appVersion: '9.9.9' },
+ store,
+ openSocket: (url, handlers) => {
+ const socket = openSocket(url, handlers);
+ sockets.push(socket);
+ return socket;
+ },
+ createPeerConnection: (config) => {
+ const peer = new LoopbackPeer(config, 'client');
+ clientPeers.push(peer);
+ return peer as unknown as RTCPeerConnection;
+ },
+ openMicrophone: () =>
+ Promise.resolve({
+ getTracks: () => [micTrack],
+ getAudioTracks: () => [micTrack],
+ } as unknown as MediaStream),
+ });
+
+ const entry: ConformanceClient = {
+ client,
+ deviceId: '',
+ name,
+ store,
+ events,
+ sockets,
+ peers: clientPeers,
+ micTrack,
+ socket: () => sockets[sockets.length - 1],
+ peer: () => clientPeers[clientPeers.length - 1],
+ desktopPeer: () => {
+ const peer = desktopPeers.get(entry.deviceId);
+ if (!peer) throw new Error(`No desktop peer for ${entry.deviceId}`);
+ return peer;
+ },
+ state: () => client.snapshot(),
+ sentFrames: (kind?: DeviceChannelKind) => channelFrames(entry.peer(), kind),
+ receivedFrames: (kind?: DeviceChannelKind) => channelFrames(entry.desktopPeer(), kind),
+ rawSentFrames: () => [
+ ...entry.peer().channel(RELIABLE_CHANNEL_LABEL).frames(),
+ ...entry.peer().channel(UNRELIABLE_CHANNEL_LABEL).frames(),
+ ],
+ dropNetwork: () => {
+ // A network drop takes the media with it. Dropping only the socket
+ // would leave a live data channel the desktop then writes `revoked`
+ // down, which is a different failure with a different meaning.
+ const peer = entry.peer();
+ const desktop = desktopPeers.get(entry.deviceId);
+ desktop?.setConnectionState('failed');
+ peer.setConnectionState('failed');
+ for (const channel of peer.channels) channel.close();
+ entry.socket().drop();
+ },
+ };
+ client.subscribe((event) => events.push(event));
+
+ client.connect({ host: SERVER_HOST, port: SERVER_PORT, token: SERVER_TOKEN, code: offer.code });
+ await advance();
+
+ // A human approves, which is the only thing a pairing code ever buys.
+ const request = transport.pairing.pendingRequest();
+ if (!request) throw new Error('The desktop never saw the pairing claim');
+ const device = await transport.pairing.approve(request.requestId);
+ if (!device) throw new Error('The desktop refused to approve the pairing request');
+
+ // The client collects the token on its next poll, authenticates, offers,
+ // and the peers link.
+ await advance(PAIR_POLL_INTERVAL_MS + 50);
+ await advance(50);
+
+ const deviceId = client.snapshot().deviceId;
+ if (!deviceId) throw new Error(`Client '${name}' never authenticated`);
+ (entry as { deviceId: string }).deviceId = deviceId;
+ clientsByDevice.set(deviceId, entry);
+ // The peer for this device was created before the id was known here, so the
+ // link runs now rather than in the factory's microtask.
+ const desktopPeer = desktopPeers.get(deviceId);
+ if (desktopPeer && !desktopPeer.remote) {
+ linkPeers(entry.peer(), desktopPeer);
+ desktopPeer.setConnectionState('connected');
+ entry.peer().setConnectionState('connected');
+ }
+ await advance(50);
+ return entry;
+ }
+
+ function openRawDevice(): RawDevice {
+ const received: SignalingServerMessage[] = [];
+ const socket = openSocket(`ws://${SERVER_HOST}:${SERVER_PORT}/${SERVER_TOKEN}/ws`, {
+ onOpen: () => {},
+ onMessage: (message) => received.push(message),
+ onClose: () => {},
+ });
+ return {
+ socket,
+ received,
+ send: async (message) => {
+ socket.send(message as SignalingClientMessage);
+ await advance();
+ },
+ last: () => received[received.length - 1],
+ ofType: (op: T) =>
+ received.filter(
+ (message): message is Extract => message.op === op
+ ),
+ };
+ }
+
+ return {
+ transport,
+ peers,
+ session,
+ floor,
+ hostCommands,
+ deviceMessages,
+ captured,
+ detached,
+ connectClient,
+ openRawDevice,
+ setACappellaEnabled: (enabled: boolean) => {
+ settings.encoreFeatures = { aCappella: enabled };
+ },
+ advance,
+ dispose: async () => {
+ transport.dispose();
+ peers.closeAll('the conformance world was torn down');
+ vi.useRealTimers();
+ // `noteConnected()` persists off a peer connecting and nobody awaits it,
+ // so a `devices.json` write can still be in flight here, and one can even
+ // be enqueued after a plain drain. Closing settles what is queued and
+ // drops what comes later, so removing the directory cannot rename into
+ // nothing.
+ await transport.pairing.close();
+ await fs.rm(userDataPath, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 20,
+ });
+ },
+ };
+}
+
+/** Decoded frames one peer put on the wire, on one channel or across both. */
+function channelFrames(peer: LoopbackPeer, kind?: DeviceChannelKind): DeviceMessage[] {
+ const labels =
+ kind === 'reliable'
+ ? [RELIABLE_CHANNEL_LABEL]
+ : kind === 'unreliable'
+ ? [UNRELIABLE_CHANNEL_LABEL]
+ : [RELIABLE_CHANNEL_LABEL, UNRELIABLE_CHANNEL_LABEL];
+ const frames: DeviceMessage[] = [];
+ for (const label of labels) {
+ const channel = peer.channels.find((entry) => entry.label === label);
+ if (channel) frames.push(...channel.messages());
+ }
+ return frames;
+}
+
+/** Every voice event of one type a client received, unwrapped. */
+export function voiceEventsFrom(frames: DeviceMessage[], type?: VoiceEvent['type']): VoiceEvent[] {
+ return frames
+ .filter((frame): frame is Extract => {
+ return frame.type === 'voice-event' && (!type || frame.event.type === type);
+ })
+ .map((frame) => frame.event);
+}
diff --git a/src/__tests__/acappella/conformance/signaling.conformance.test.ts b/src/__tests__/acappella/conformance/signaling.conformance.test.ts
new file mode 100644
index 0000000000..472b267fb7
--- /dev/null
+++ b/src/__tests__/acappella/conformance/signaling.conformance.test.ts
@@ -0,0 +1,264 @@
+/**
+ * Protocol conformance: the signaling layer, C-01 to C-15.
+ *
+ * Every assertion here is one row of the checklist in
+ * `docs/ios-client/protocol-conformance.md`, run against the real desktop stack
+ * rather than against a description of it. Where an item is a rule about what a
+ * CLIENT does, the reference client is the subject; where it is a rule the
+ * desktop enforces, a raw device sends the frame a conforming client never
+ * would, because a limit nobody has hit is a limit nobody has tested.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { DEFAULT_STUN_URLS } from '../../../main/acappella/transport/ice-config';
+import { AUTH_ATTEMPT_LIMIT, OFFER_RATE_LIMIT } from '../../../main/acappella/transport/signaling';
+import { ACAPPELLA_SIGNAL_MESSAGE } from '../../../shared/acappella/signaling-protocol';
+import { DEFAULT_REMOTE_AUDIO_CONFIG } from '../../../shared/acappella/webrtc-host';
+import {
+ SERVER_HOST,
+ SERVER_PORT,
+ SERVER_TOKEN,
+ createConformanceWorld,
+ type ConformanceWorld,
+} from './harness';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+vi.mock('../../../renderer/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let world: ConformanceWorld;
+
+beforeEach(async () => {
+ world = await createConformanceWorld();
+});
+
+afterEach(async () => {
+ await world.dispose();
+});
+
+describe('conformance: pairing and authentication', () => {
+ it('carries every frame in the acappella_signal envelope on /$TOKEN/ws (C-01)', async () => {
+ const device = await world.connectClient();
+
+ // One socket, one token, no second port.
+ expect(device.socket().url).toBe(`ws://${SERVER_HOST}:${SERVER_PORT}/${SERVER_TOKEN}/ws`);
+ expect(device.sockets).toHaveLength(1);
+ for (const envelope of device.sockets[0].outbound) {
+ expect(envelope.type).toBe(ACAPPELLA_SIGNAL_MESSAGE);
+ expect(typeof (envelope.payload as { op: string }).op).toBe('string');
+ }
+ });
+
+ it('claims with a non-empty name and platform, and the desktop shows both (C-02)', async () => {
+ const offer = world.transport.startPairing();
+ const raw = world.openRawDevice();
+ await raw.send({
+ op: 'pair-claim',
+ code: offer?.code,
+ name: "Pedram's iPhone",
+ platform: 'ios',
+ });
+
+ const request = world.transport.pairing.pendingRequest();
+ expect(request).toMatchObject({ name: "Pedram's iPhone", platform: 'ios' });
+ // A non-string is coerced to the empty string rather than refused, which is
+ // how a nameless row appears in the approval sheet.
+ expect(raw.ofType('pair-pending')).toHaveLength(1);
+ });
+
+ it('polls on an interval and keeps the deadline from the FIRST pair-pending (C-03)', async () => {
+ const offer = world.transport.startPairing();
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'pair-claim', code: offer?.code, name: 'Phone', platform: 'ios' });
+ const first = raw.ofType('pair-pending')[0];
+ expect(first.expiresAt).toBeGreaterThan(Date.now());
+
+ await raw.send({ op: 'pair-poll', requestId: first.requestId });
+ // The answer to a poll carries no deadline at all. A client that takes it
+ // collapses its own countdown to 1970.
+ expect(raw.ofType('pair-pending')[1].expiresAt).toBe(0);
+ });
+
+ it('stores the token the desktop issued, then authenticates with it (C-04, C-06)', async () => {
+ const device = await world.connectClient();
+
+ expect(device.store.value).toMatchObject({ deviceId: device.deviceId });
+ expect(device.store.value?.token).toBeTruthy();
+ const auth = device.sockets[0].outbound
+ .map((envelope) => envelope.payload as Record)
+ .find((payload) => payload.op === 'auth');
+ expect(auth).toMatchObject({ deviceId: device.deviceId });
+ expect(Number.isInteger(auth?.protocolVersion as number)).toBe(true);
+ expect(auth?.protocolVersion as number).toBeGreaterThanOrEqual(1);
+ });
+
+ it('shows the desktop"s rejection sentence verbatim (C-05)', async () => {
+ world.transport.startPairing();
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'pair-claim', code: 'WRONG1', name: 'Phone', platform: 'ios' });
+
+ expect(raw.last()).toEqual({
+ op: 'pair-rejected',
+ reason: 'unknown-code',
+ message: 'That pairing code does not match the one on the desktop.',
+ });
+ });
+
+ it('refuses an offer and a candidate before auth on the same socket (C-07)', async () => {
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'offer', sdp: { type: 'offer', sdp: 'v=0' } });
+ await raw.send({
+ op: 'ice-candidate',
+ candidate: { candidate: 'candidate:1 1 udp 1 h 1 typ host' },
+ });
+
+ expect(raw.ofType('error').map((error) => error.code)).toEqual([
+ 'not-authenticated',
+ 'not-authenticated',
+ ]);
+ expect(world.hostCommands).toHaveLength(0);
+ });
+
+ it('never inherits an authenticated state across sockets (C-07, C-09)', async () => {
+ const device = await world.connectClient();
+ const stored = device.store.value;
+ expect(stored).not.toBeNull();
+
+ // A second socket for the same device starts from nothing.
+ const raw = world.openRawDevice();
+ await raw.send({ op: 'offer', sdp: { type: 'offer', sdp: 'v=0' } });
+ expect(raw.ofType('error')[0].code).toBe('not-authenticated');
+ });
+
+ it('rate limits offers past the budget the client stays under (C-08)', async () => {
+ const device = await world.connectClient();
+ const raw = world.openRawDevice();
+ await raw.send({
+ op: 'auth',
+ deviceId: device.deviceId,
+ token: device.store.value?.token,
+ protocolVersion: 1,
+ });
+ expect(raw.ofType('authenticated')).toHaveLength(1);
+
+ for (let i = 0; i < OFFER_RATE_LIMIT; i += 1) {
+ await raw.send({ op: 'offer', sdp: { type: 'offer', sdp: 'v=0' } });
+ }
+ expect(raw.ofType('error')).toHaveLength(0);
+
+ await raw.send({ op: 'offer', sdp: { type: 'offer', sdp: 'v=0' } });
+ expect(raw.ofType('error')[0].code).toBe('rate-limited');
+ });
+
+ it('cuts a socket off after too many failed auths (C-09)', async () => {
+ const raw = world.openRawDevice();
+ for (let i = 0; i < AUTH_ATTEMPT_LIMIT; i += 1) {
+ await raw.send({ op: 'auth', deviceId: 'nope', token: 'nope', protocolVersion: 1 });
+ }
+ expect(raw.ofType('auth-failed')).toHaveLength(AUTH_ATTEMPT_LIMIT);
+
+ await raw.send({ op: 'auth', deviceId: 'nope', token: 'nope', protocolVersion: 1 });
+ expect(raw.ofType('error')[0].code).toBe('rate-limited');
+ // The recovery is a new socket, and a new socket really does start clean.
+ const second = world.openRawDevice();
+ await second.send({ op: 'auth', deviceId: 'nope', token: 'nope', protocolVersion: 1 });
+ expect(second.ofType('auth-failed')).toHaveLength(1);
+ });
+
+ it('sends exactly one auth per socket (C-09)', async () => {
+ const device = await world.connectClient();
+ expect(device.sockets[0].ops().filter((op) => op === 'auth')).toHaveLength(1);
+ });
+});
+
+describe('conformance: what authenticated carries', () => {
+ it('builds the peer from the ICE servers the desktop sent, and hard-codes none (C-10)', async () => {
+ const device = await world.connectClient();
+
+ expect(device.peer().config.iceServers).toEqual([{ urls: [...DEFAULT_STUN_URLS] }]);
+ expect(device.peer().config.iceTransportPolicy).toBe('all');
+ });
+
+ it('applies the desktop audio config to the offer, and gets it back in the answer (C-11)', async () => {
+ const device = await world.connectClient();
+
+ const offer = device.sockets[0].outbound
+ .map((envelope) => envelope.payload as { op: string; sdp?: { sdp: string } })
+ .find((payload) => payload.op === 'offer');
+ expect(offer?.sdp?.sdp).toContain('useinbandfec=1');
+ expect(offer?.sdp?.sdp).toContain('usedtx=1');
+ expect(offer?.sdp?.sdp).toContain(
+ `maxaveragebitrate=${DEFAULT_REMOTE_AUDIO_CONFIG.maxAverageBitrate}`
+ );
+ // And the desktop answered with the same shaping rather than the client"s.
+ expect(device.desktopPeer().localDescription?.sdp).toContain('useinbandfec=1');
+ });
+
+ it('trickles candidates both ways once, and only once, authenticated (C-07)', async () => {
+ const device = await world.connectClient();
+ device.peer().gatherCandidate('candidate:1 1 udp 1 10.0.0.2 1 typ host');
+ await world.advance();
+
+ expect(device.desktopPeer().candidates).toHaveLength(1);
+ expect(device.desktopPeer().candidates[0].candidate).toContain('typ host');
+ });
+});
+
+describe('conformance: teardown', () => {
+ it('sends bye before a deliberate teardown, and the desktop lets go (C-14)', async () => {
+ const device = await world.connectClient();
+ device.client.disconnect();
+ await world.advance();
+
+ expect(device.socket().ops()).toContain('bye');
+ expect(world.transport.signaling.isOnline(device.deviceId)).toBe(false);
+ });
+
+ it('treats a closed session as terminal and does not reconnect (C-12)', async () => {
+ const device = await world.connectClient();
+ world.transport.disconnectAll('the desktop disconnected all devices');
+ await world.advance();
+
+ expect(device.state().phase).toBe('terminal');
+ await world.advance(60_000);
+ expect(device.sockets).toHaveLength(1);
+ });
+
+ it('restarts ICE and re-offers on peer-failed rather than re-pairing (C-15)', async () => {
+ const device = await world.connectClient();
+ const before = device
+ .socket()
+ .ops()
+ .filter((op) => op === 'offer').length;
+ const claimsBefore = device
+ .socket()
+ .ops()
+ .filter((op) => op === 'pair-claim').length;
+
+ // The desktop's peer died, which is what the audio host reports.
+ world.transport.handleHostEvent({
+ kind: 'peer-error',
+ deviceId: device.deviceId,
+ message: 'The connection to this device failed.',
+ });
+ await world.advance();
+
+ const after = device
+ .socket()
+ .ops()
+ .filter((op) => op === 'offer').length;
+ expect(after).toBe(before + 1);
+ // Still the same pairing: nothing claimed a second code.
+ expect(
+ device
+ .socket()
+ .ops()
+ .filter((op) => op === 'pair-claim')
+ ).toHaveLength(claimsBefore);
+ expect(device.store.value).not.toBeNull();
+ });
+});
diff --git a/src/__tests__/helpers/index.ts b/src/__tests__/helpers/index.ts
index ec2927904d..600f3ce176 100644
--- a/src/__tests__/helpers/index.ts
+++ b/src/__tests__/helpers/index.ts
@@ -8,4 +8,12 @@
export { createMockAITab, createMockFileTab } from './mockTab';
export { createMockSession } from './mockSession';
export { installLocalStorageMock } from './mockLocalStorage';
+export {
+ createFakeAudioBuffer,
+ createFakeAudioContext,
+ createFakeGainNode,
+ createFakeMediaStream,
+ installAudioWorkletNodeMock,
+ installMediaDevicesMock,
+} from './mockWebAudio';
export { ALL_RENDERER_STORES, resetAllStores, resetStore, resetStores } from './resetStores';
diff --git a/src/__tests__/helpers/mockWebAudio.ts b/src/__tests__/helpers/mockWebAudio.ts
new file mode 100644
index 0000000000..bdade4fa0f
--- /dev/null
+++ b/src/__tests__/helpers/mockWebAudio.ts
@@ -0,0 +1,376 @@
+/**
+ * Shared Web Audio test doubles.
+ *
+ * jsdom implements none of the Web Audio API, and A Cappella's audio host is
+ * built entirely on it. Rather than let each suite hand-roll another half of an
+ * `AudioContext`, this is the one fake: a controllable clock, a node graph that
+ * records its own connections, and a `getUserMedia` that can be told to fail
+ * with any DOM exception name.
+ *
+ * The fakes record rather than assert. Suites check what they care about
+ * (connections made, sources stopped, gain ramps scheduled) off the recorded
+ * state.
+ *
+ * Usage:
+ *
+ * import { createFakeAudioContext, installMediaDevicesMock } from '/helpers/mockWebAudio';
+ *
+ * const context = createFakeAudioContext();
+ * const media = installMediaDevicesMock();
+ * media.failWith('NotAllowedError');
+ */
+
+import { vi } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Node graph
+// ---------------------------------------------------------------------------
+
+export interface FakeAudioNode {
+ connect: ReturnType;
+ disconnect: ReturnType;
+ /** Everything this node has been connected to, in order. */
+ connectedTo: unknown[];
+ disconnectCount: number;
+}
+
+function createNode(): FakeAudioNode {
+ const node: FakeAudioNode = {
+ connectedTo: [],
+ disconnectCount: 0,
+ connect: vi.fn((target: unknown) => {
+ node.connectedTo.push(target);
+ return target;
+ }),
+ disconnect: vi.fn(() => {
+ node.disconnectCount += 1;
+ }),
+ };
+ return node;
+}
+
+/** One scheduled automation on a fake `AudioParam`. */
+export interface GainAutomation {
+ kind: 'cancel' | 'set' | 'ramp';
+ value: number;
+ time: number;
+}
+
+export interface FakeGainNode extends FakeAudioNode {
+ gain: {
+ value: number;
+ cancelScheduledValues(time: number): void;
+ setValueAtTime(value: number, time: number): void;
+ linearRampToValueAtTime(value: number, time: number): void;
+ };
+ automations: GainAutomation[];
+}
+
+export function createFakeGainNode(): FakeGainNode {
+ const automations: GainAutomation[] = [];
+ const node = createNode() as FakeGainNode;
+ node.automations = automations;
+ node.gain = {
+ value: 1,
+ cancelScheduledValues(time: number) {
+ automations.push({ kind: 'cancel', value: node.gain.value, time });
+ },
+ setValueAtTime(value: number, time: number) {
+ automations.push({ kind: 'set', value, time });
+ node.gain.value = value;
+ },
+ linearRampToValueAtTime(value: number, time: number) {
+ automations.push({ kind: 'ramp', value, time });
+ node.gain.value = value;
+ },
+ };
+ return node;
+}
+
+export interface FakeBufferSourceNode extends FakeAudioNode {
+ buffer: FakeAudioBuffer | null;
+ onended: (() => void) | null;
+ startedAt: number | null;
+ stopped: boolean;
+ start(when?: number): void;
+ stop(when?: number): void;
+ /** Test-only: run the `onended` callback as the renderer would. */
+ finish(): void;
+}
+
+export interface FakeAudioBuffer {
+ numberOfChannels: number;
+ length: number;
+ sampleRate: number;
+ duration: number;
+ getChannelData(channel: number): Float32Array;
+}
+
+export function createFakeAudioBuffer(
+ length: number,
+ sampleRate: number,
+ channels = 1
+): FakeAudioBuffer {
+ const data = Array.from({ length: channels }, () => new Float32Array(length));
+ return {
+ numberOfChannels: channels,
+ length,
+ sampleRate,
+ duration: length / sampleRate,
+ getChannelData: (channel: number) => data[channel],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+export interface FakeAudioContext {
+ state: AudioContextState;
+ sampleRate: number;
+ currentTime: number;
+ destination: FakeAudioNode;
+ /** Every buffer source the context has handed out. */
+ sources: FakeBufferSourceNode[];
+ gains: FakeGainNode[];
+ addedModules: string[];
+ closed: boolean;
+ resume: ReturnType;
+ close: ReturnType;
+ audioWorklet: { addModule: ReturnType };
+ createGain(): FakeGainNode;
+ createBufferSource(): FakeBufferSourceNode;
+ createMediaStreamSource(stream: unknown): FakeAudioNode & { mediaStream: unknown };
+ /** The outbound tap the WebRTC leg connects playback into. */
+ createMediaStreamDestination(): FakeAudioNode & { stream: { getAudioTracks(): unknown[] } };
+ createBuffer(channels: number, length: number, sampleRate: number): FakeAudioBuffer;
+ decodeAudioData: ReturnType;
+ /** Test-only: advance the audio clock. */
+ advance(seconds: number): void;
+}
+
+export interface FakeAudioContextOptions {
+ sampleRate?: number;
+ state?: AudioContextState;
+ /** Make `audioWorklet.addModule` reject, as a malformed worklet chunk would. */
+ addModuleError?: Error;
+}
+
+export function createFakeAudioContext(options: FakeAudioContextOptions = {}): FakeAudioContext {
+ const context: FakeAudioContext = {
+ state: options.state ?? 'running',
+ sampleRate: options.sampleRate ?? 48000,
+ currentTime: 0,
+ destination: createNode(),
+ sources: [],
+ gains: [],
+ addedModules: [],
+ closed: false,
+ resume: vi.fn(async () => {
+ context.state = 'running';
+ }),
+ close: vi.fn(async () => {
+ context.closed = true;
+ context.state = 'closed';
+ }),
+ audioWorklet: {
+ addModule: vi.fn(async (url: string) => {
+ if (options.addModuleError) throw options.addModuleError;
+ context.addedModules.push(url);
+ }),
+ },
+ createGain: () => {
+ const gain = createFakeGainNode();
+ context.gains.push(gain);
+ return gain;
+ },
+ createBufferSource: () => {
+ const source = createNode() as FakeBufferSourceNode;
+ source.buffer = null;
+ source.onended = null;
+ source.startedAt = null;
+ source.stopped = false;
+ source.start = (when = 0) => {
+ source.startedAt = when;
+ };
+ source.stop = () => {
+ source.stopped = true;
+ };
+ source.finish = () => {
+ source.onended?.();
+ };
+ context.sources.push(source);
+ return source;
+ },
+ createMediaStreamSource: (stream: unknown) =>
+ Object.assign(createNode(), { mediaStream: stream }),
+ createMediaStreamDestination: () => {
+ const track = { kind: 'audio', id: 'outbound' };
+ return Object.assign(createNode(), {
+ stream: { getAudioTracks: () => [track] },
+ });
+ },
+ createBuffer: (channels: number, length: number, sampleRate: number) =>
+ createFakeAudioBuffer(length, sampleRate, channels),
+ decodeAudioData: vi.fn(async (data: ArrayBuffer) =>
+ createFakeAudioBuffer(data.byteLength / 2, 24000)
+ ),
+ advance: (seconds: number) => {
+ context.currentTime += seconds;
+ },
+ };
+ return context;
+}
+
+// ---------------------------------------------------------------------------
+// Media devices
+// ---------------------------------------------------------------------------
+
+export interface FakeMediaStreamTrack {
+ kind: 'audio';
+ label: string;
+ readyState: 'live' | 'ended';
+ stop: ReturnType;
+ getSettings(): { deviceId: string };
+ addEventListener(type: string, listener: () => void): void;
+ removeEventListener(type: string, listener: () => void): void;
+ /** Test-only: fire `ended`, as unplugging a headset does. */
+ end(): void;
+}
+
+export interface FakeMediaStream {
+ getTracks(): FakeMediaStreamTrack[];
+ getAudioTracks(): FakeMediaStreamTrack[];
+ track: FakeMediaStreamTrack;
+}
+
+export function createFakeMediaStream(label = 'MacBook Pro Microphone'): FakeMediaStream {
+ const listeners = new Map void>>();
+ const track: FakeMediaStreamTrack = {
+ kind: 'audio',
+ label,
+ readyState: 'live',
+ stop: vi.fn(() => {
+ track.readyState = 'ended';
+ }),
+ getSettings: () => ({ deviceId: 'default' }),
+ addEventListener: (type, listener) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type)!.add(listener);
+ },
+ removeEventListener: (type, listener) => {
+ listeners.get(type)?.delete(listener);
+ },
+ end: () => {
+ track.readyState = 'ended';
+ listeners.get('ended')?.forEach((listener) => listener());
+ },
+ };
+ return {
+ track,
+ getTracks: () => [track],
+ getAudioTracks: () => [track],
+ };
+}
+
+export interface MediaDevicesMock {
+ getUserMedia: ReturnType;
+ stream: FakeMediaStream;
+ /** Reject the next `getUserMedia` with a DOMException of this name. */
+ failWith(name: string, message?: string): void;
+ /** Test-only: fire `devicechange`. */
+ emitDeviceChange(): void;
+ listenerCount(): number;
+ restore(): void;
+}
+
+/**
+ * Install `navigator.mediaDevices` on the jsdom window. Returns a handle for
+ * driving it, plus a `restore` that puts the original descriptor back.
+ */
+export function installMediaDevicesMock(): MediaDevicesMock {
+ const stream = createFakeMediaStream();
+ const listeners = new Set<() => void>();
+ let failure: Error | null = null;
+
+ const mediaDevices = {
+ getUserMedia: vi.fn(async () => {
+ if (failure) {
+ const error = failure;
+ failure = null;
+ throw error;
+ }
+ return stream;
+ }),
+ addEventListener: (_type: string, listener: () => void) => {
+ listeners.add(listener);
+ },
+ removeEventListener: (_type: string, listener: () => void) => {
+ listeners.delete(listener);
+ },
+ };
+
+ const original = Object.getOwnPropertyDescriptor(navigator, 'mediaDevices');
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: mediaDevices,
+ configurable: true,
+ writable: true,
+ });
+
+ return {
+ getUserMedia: mediaDevices.getUserMedia,
+ stream,
+ failWith: (name: string, message = name) => {
+ const error = new Error(message);
+ error.name = name;
+ failure = error;
+ },
+ emitDeviceChange: () => listeners.forEach((listener) => listener()),
+ listenerCount: () => listeners.size,
+ restore: () => {
+ if (original) Object.defineProperty(navigator, 'mediaDevices', original);
+ else delete (navigator as unknown as Record).mediaDevices;
+ },
+ };
+}
+
+/**
+ * Stub the global `AudioWorkletNode` constructor. Returns the list of nodes it
+ * built so a suite can drive `port.onmessage` the way the worklet would.
+ */
+export interface FakeAudioWorkletNode extends FakeAudioNode {
+ name: string;
+ options: unknown;
+ port: { onmessage: ((event: { data: unknown }) => void) | null };
+ /** Test-only: deliver a message from the audio thread. */
+ emit(data: unknown): void;
+}
+
+export function installAudioWorkletNodeMock(): {
+ nodes: FakeAudioWorkletNode[];
+ restore(): void;
+} {
+ const nodes: FakeAudioWorkletNode[] = [];
+
+ class MockAudioWorkletNode {
+ constructor(_context: unknown, name: string, options?: unknown) {
+ const node = createNode() as FakeAudioWorkletNode;
+ node.name = name;
+ node.options = options;
+ node.port = { onmessage: null };
+ node.emit = (data: unknown) => node.port.onmessage?.({ data });
+ nodes.push(node);
+ return node as unknown as MockAudioWorkletNode;
+ }
+ }
+
+ const original = (globalThis as Record).AudioWorkletNode;
+ (globalThis as Record).AudioWorkletNode = MockAudioWorkletNode;
+
+ return {
+ nodes,
+ restore: () => {
+ (globalThis as Record).AudioWorkletNode = original;
+ },
+ };
+}
diff --git a/src/__tests__/main/acappella/audio-host-window.test.ts b/src/__tests__/main/acappella/audio-host-window.test.ts
new file mode 100644
index 0000000000..c4c25036fd
--- /dev/null
+++ b/src/__tests__/main/acappella/audio-host-window.test.ts
@@ -0,0 +1,250 @@
+/**
+ * A Cappella hidden audio host window.
+ *
+ * The window itself is untestable without an Electron runtime, so the
+ * `BrowserWindow` constructor is mocked and the assertions are about the
+ * contract the rest of the app depends on: it is never visible, never
+ * throttled, registered as a feature kind (so the multi-window machinery skips
+ * it), created at most once, and fully deregistered when it closes.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { WindowRegistry } from '../../../main/window-registry';
+
+interface MockWebContents {
+ setWindowOpenHandler: ReturnType;
+ on: ReturnType;
+}
+
+class MockBrowserWindow {
+ static instances: MockBrowserWindow[] = [];
+ static lastOptions: Record | null = null;
+
+ options: Record;
+ destroyed = false;
+ excludedFromShownWindowsMenu = false;
+ loadedUrls: string[] = [];
+ handlers = new Map void>>();
+ webContents: MockWebContents;
+ webContentsHandlers = new Map void>>();
+
+ constructor(options: Record) {
+ this.options = options;
+ MockBrowserWindow.lastOptions = options;
+ MockBrowserWindow.instances.push(this);
+ this.webContents = {
+ setWindowOpenHandler: vi.fn(),
+ on: vi.fn((event: string, handler: (...args: any[]) => void) => {
+ const list = this.webContentsHandlers.get(event) ?? [];
+ list.push(handler);
+ this.webContentsHandlers.set(event, list);
+ }),
+ };
+ }
+
+ isDestroyed(): boolean {
+ return this.destroyed;
+ }
+
+ loadURL(url: string): Promise {
+ this.loadedUrls.push(url);
+ return Promise.resolve();
+ }
+
+ on(event: string, handler: (...args: any[]) => void): this {
+ const list = this.handlers.get(event) ?? [];
+ list.push(handler);
+ this.handlers.set(event, list);
+ return this;
+ }
+
+ close(): void {
+ this.destroyed = true;
+ for (const handler of this.handlers.get('closed') ?? []) handler();
+ }
+
+ emitWebContents(event: string, ...args: any[]): void {
+ for (const handler of this.webContentsHandlers.get(event) ?? []) handler(...args);
+ }
+}
+
+// A GETTER, not `BrowserWindow: MockBrowserWindow`. `vi.mock` is hoisted, so
+// this factory runs the moment any imported module pulls in `electron` - which
+// happens while this file's own body (and therefore the class binding below) is
+// still in its temporal dead zone. Reading the class at property-access time
+// instead of factory time makes the mock independent of which import happens to
+// reach electron first.
+vi.mock('electron', () => ({
+ get BrowserWindow() {
+ return MockBrowserWindow;
+ },
+}));
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+const PROD_DEPS_BASE = {
+ preloadPath: '/app/dist/main/preload.js',
+ rendererProductionUrl: 'maestro://renderer/index.html',
+ devServerUrl: 'http://localhost:17173',
+};
+
+async function loadModule() {
+ return import('../../../main/acappella/audio-host-window');
+}
+
+describe('main/acappella/audio-host-window', () => {
+ let registry: WindowRegistry;
+ const originalPlatform = process.platform;
+
+ beforeEach(() => {
+ vi.resetModules();
+ MockBrowserWindow.instances = [];
+ MockBrowserWindow.lastOptions = null;
+ registry = new WindowRegistry();
+ });
+
+ afterEach(() => {
+ // Module state is per-test (resetModules above), so only the platform
+ // override has to be undone.
+ Object.defineProperty(process, 'platform', {
+ value: originalPlatform,
+ configurable: true,
+ });
+ });
+
+ const deps = (overrides: Partial<{ isDevelopment: boolean }> = {}) => ({
+ ...PROD_DEPS_BASE,
+ isDevelopment: false,
+ windowRegistry: registry,
+ ...overrides,
+ });
+
+ it('creates a hidden, untracked-by-the-OS window that is never background throttled', async () => {
+ const { ensureAcappellaAudioHostWindow } = await loadModule();
+ ensureAcappellaAudioHostWindow(deps());
+
+ const options = MockBrowserWindow.lastOptions!;
+ expect(options.show).toBe(false);
+ expect(options.skipTaskbar).toBe(true);
+ expect(options.focusable).toBe(false);
+ expect(options.paintWhenInitiallyHidden).toBe(false);
+ // A throttled timer in the audio path is an audible dropout.
+ expect(options.webPreferences.backgroundThrottling).toBe(false);
+ expect(options.webPreferences.contextIsolation).toBe(true);
+ expect(options.webPreferences.nodeIntegration).toBe(false);
+ expect(options.webPreferences.preload).toBe(PROD_DEPS_BASE.preloadPath);
+ });
+
+ it('boots the renderer bundle into audio-host mode in both dev and production', async () => {
+ const { ensureAcappellaAudioHostWindow, closeAcappellaAudioHostWindow } = await loadModule();
+
+ ensureAcappellaAudioHostWindow(deps());
+ expect(MockBrowserWindow.instances[0].loadedUrls).toEqual([
+ 'maestro://renderer/index.html?acappellaAudio',
+ ]);
+
+ closeAcappellaAudioHostWindow();
+ ensureAcappellaAudioHostWindow(deps({ isDevelopment: true }));
+ expect(MockBrowserWindow.instances[1].loadedUrls).toEqual([
+ 'http://localhost:17173?acappellaAudio',
+ ]);
+ });
+
+ it('registers as an acappella-audio feature window the multi-window machinery skips', async () => {
+ const { ensureAcappellaAudioHostWindow } = await loadModule();
+ ensureAcappellaAudioHostWindow(deps());
+
+ const entry = registry.getByKind('acappella-audio');
+ expect(entry).toBeDefined();
+ expect(entry!.sessionIds).toEqual([]);
+ expect(entry!.isMain).toBe(false);
+ // "Move to Window", persistence, auto-close and telemetry all read
+ // getAppWindows(); the audio host must not be offered to any of them.
+ expect(registry.getAppWindows()).toHaveLength(0);
+ });
+
+ it('is created once no matter how many sessions start', async () => {
+ const { ensureAcappellaAudioHostWindow, getAcappellaAudioHostWindow } = await loadModule();
+
+ const first = ensureAcappellaAudioHostWindow(deps());
+ const second = ensureAcappellaAudioHostWindow(deps());
+
+ expect(second).toBe(first);
+ expect(MockBrowserWindow.instances).toHaveLength(1);
+ expect(getAcappellaAudioHostWindow()).toBe(first);
+ });
+
+ it('deregisters and forgets the window when it closes, and rebuilds on the next start', async () => {
+ const {
+ ensureAcappellaAudioHostWindow,
+ getAcappellaAudioHostWindow,
+ closeAcappellaAudioHostWindow,
+ } = await loadModule();
+
+ ensureAcappellaAudioHostWindow(deps());
+ closeAcappellaAudioHostWindow();
+
+ expect(getAcappellaAudioHostWindow()).toBeNull();
+ expect(registry.getAll()).toHaveLength(0);
+
+ ensureAcappellaAudioHostWindow(deps());
+ expect(MockBrowserWindow.instances).toHaveLength(2);
+ expect(registry.getByKind('acappella-audio')).toBeDefined();
+ });
+
+ it('closing when nothing is open is a no-op', async () => {
+ const { closeAcappellaAudioHostWindow } = await loadModule();
+ expect(() => closeAcappellaAudioHostWindow()).not.toThrow();
+ expect(MockBrowserWindow.instances).toHaveLength(0);
+ });
+
+ it('denies popups and any navigation away from its own entry url', async () => {
+ const { ensureAcappellaAudioHostWindow } = await loadModule();
+ ensureAcappellaAudioHostWindow(deps());
+ const win = MockBrowserWindow.instances[0];
+
+ const openHandler = win.webContents.setWindowOpenHandler.mock.calls[0][0] as () => {
+ action: string;
+ };
+ expect(openHandler()).toEqual({ action: 'deny' });
+
+ const sameUrl = { preventDefault: vi.fn() };
+ win.emitWebContents('will-navigate', sameUrl, 'maestro://renderer/index.html?acappellaAudio');
+ expect(sameUrl.preventDefault).not.toHaveBeenCalled();
+
+ const elsewhere = { preventDefault: vi.fn() };
+ win.emitWebContents('will-navigate', elsewhere, 'https://example.com');
+ expect(elsewhere.preventDefault).toHaveBeenCalled();
+ });
+
+ it('grants the microphone only to its own web contents', async () => {
+ const { ensureAcappellaAudioHostWindow, isAcappellaAudioHostContents } = await loadModule();
+
+ expect(isAcappellaAudioHostContents({} as never)).toBe(false);
+
+ ensureAcappellaAudioHostWindow(deps());
+ const win = MockBrowserWindow.instances[0];
+
+ expect(isAcappellaAudioHostContents(win.webContents as never)).toBe(true);
+ expect(isAcappellaAudioHostContents({} as never)).toBe(false);
+ expect(isAcappellaAudioHostContents(null)).toBe(false);
+ });
+
+ it('hides itself from the macOS Window menu, and touches nothing elsewhere', async () => {
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
+ const macModule = await loadModule();
+ macModule.ensureAcappellaAudioHostWindow(deps());
+ expect(MockBrowserWindow.instances[0].excludedFromShownWindowsMenu).toBe(true);
+ macModule.closeAcappellaAudioHostWindow();
+
+ vi.resetModules();
+ MockBrowserWindow.instances = [];
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
+ const winModule = await loadModule();
+ winModule.ensureAcappellaAudioHostWindow(deps());
+ expect(MockBrowserWindow.instances[0].excludedFromShownWindowsMenu).toBe(false);
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/audio-bridge.test.ts b/src/__tests__/main/acappella/audio/audio-bridge.test.ts
new file mode 100644
index 0000000000..d5544d0b5e
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/audio-bridge.test.ts
@@ -0,0 +1,580 @@
+/**
+ * @file audio-bridge.test.ts
+ *
+ * The composition root where capture, the meter, the microphone projection, and
+ * playback meet the session.
+ *
+ * The bridge is where "who opens the microphone, and when" is decided, so these
+ * tests are mostly about edges the individual modules cannot see: a text-in
+ * provider that must never cost the user a permission prompt, a host renderer
+ * that boots after the session already asked for capture, and a speech run that
+ * was cut off rather than finished.
+ *
+ * No Electron and no audio device: the session is a fake and the command sink is
+ * an array.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import {
+ VoiceAudioBridge,
+ createVoiceAudioBridge,
+} from '../../../../main/acappella/audio/audio-bridge';
+import type { AudioBridgeSession } from '../../../../main/acappella/audio/audio-bridge';
+import {
+ ACAPPELLA_AUDIO_FRAME_SAMPLES,
+ ACAPPELLA_AUDIO_SAMPLE_RATE,
+ type AudioFrame,
+ type AudioHostCommand,
+ type AudioHostErrorCode,
+} from '../../../../shared/acappella/audio-host';
+import type { MicState, VoiceEvent } from '../../../../shared/acappella/protocol';
+import type { SttCallbacks, SttProvider, TtsChunk } from '../../../../shared/acappella/providers';
+import type { VoiceSessionState } from '../../../../shared/acappella/session-state';
+
+// ---------------------------------------------------------------------------
+// Signal generators
+// ---------------------------------------------------------------------------
+
+function build(fill: (index: number) => number): Int16Array {
+ const samples = new Int16Array(ACAPPELLA_AUDIO_FRAME_SAMPLES);
+ for (let i = 0; i < ACAPPELLA_AUDIO_FRAME_SAMPLES; i++) {
+ const value = Math.max(-1, Math.min(1, fill(i)));
+ samples[i] = value < 0 ? value * 0x8000 : value * 0x7fff;
+ }
+ return samples;
+}
+
+const silence = (): Int16Array => build(() => 0);
+
+const tone = (amplitude = 0.4): Int16Array =>
+ build((i) => amplitude * Math.sin((2 * Math.PI * 200 * i) / ACAPPELLA_AUDIO_SAMPLE_RATE));
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+class FakeStt implements SttProvider {
+ readonly id = 'fake-stt';
+ readonly label = 'Fake STT';
+ readonly tier = 'mock' as const;
+ readonly sampleRate = ACAPPELLA_AUDIO_SAMPLE_RATE;
+
+ readonly fed: Int16Array[] = [];
+ flushes = 0;
+ flushError: Error | null = null;
+
+ constructor(readonly acceptsAudio: boolean) {}
+
+ async start(_callbacks: SttCallbacks): Promise {}
+ feed(pcm: Int16Array): void {
+ this.fed.push(pcm);
+ }
+ async flush(): Promise {
+ this.flushes += 1;
+ if (this.flushError) throw this.flushError;
+ }
+ async stop(): Promise {}
+}
+
+class FakeSession implements AudioBridgeSession {
+ state: VoiceSessionState = 'idle';
+ stt: FakeStt | null = null;
+ interrupts = 0;
+ readonly levels: Array<{ level: number; speech: boolean }> = [];
+ readonly micStates: MicState[] = [];
+ readonly failures: Array<{ code: AudioHostErrorCode; message: string }> = [];
+
+ private readonly listeners = new Set<(event: VoiceEvent) => void>();
+
+ getState(): VoiceSessionState {
+ return this.state;
+ }
+ interrupt(): boolean {
+ if (this.state !== 'speaking') return false;
+ this.interrupts += 1;
+ this.state = 'listening';
+ return true;
+ }
+ subscribe(listener: (event: VoiceEvent) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+ getActiveStt(): SttProvider | null {
+ return this.state === 'idle' ? null : this.stt;
+ }
+ publishAudioLevel(level: number, speech: boolean): void {
+ this.levels.push({ level, speech });
+ }
+ publishMicState(state: MicState): void {
+ this.micStates.push(state);
+ }
+ reportAudioCaptureFailure(code: AudioHostErrorCode, message: string): void {
+ this.failures.push({ code, message });
+ }
+
+ /** Emit a protocol event the way the real service fans out to its subscribers. */
+ emit(event: Partial & { type: VoiceEvent['type'] }): void {
+ const full = { sessionId: 'voice-1', seq: 1, ts: 0, ...event } as VoiceEvent;
+ for (const listener of [...this.listeners]) listener(full);
+ }
+
+ /** Open the floor the way `startSession` does: state first, then the event. */
+ listen(): void {
+ this.state = 'listening';
+ this.emit({ type: 'listen-start', scope: { kind: 'conductor' }, sttProviderId: 'fake-stt' });
+ }
+}
+
+interface Harness {
+ bridge: VoiceAudioBridge;
+ session: FakeSession;
+ stt: FakeStt;
+ commands: AudioHostCommand[];
+ kinds: () => string[];
+ push(samples: Int16Array, count?: number): void;
+}
+
+let sequence = 0;
+
+function frame(samples: Int16Array): AudioFrame {
+ sequence += 1;
+ return {
+ seq: sequence,
+ capturedAt: 1_000 + sequence * 20,
+ rms: 0,
+ pcm: samples.buffer.slice(0) as ArrayBuffer,
+ };
+}
+
+function harness(options: { acceptsAudio?: boolean } = {}): Harness {
+ sequence = 0;
+ const session = new FakeSession();
+ const stt = new FakeStt(options.acceptsAudio ?? true);
+ session.stt = stt;
+ const commands: AudioHostCommand[] = [];
+
+ const bridge = createVoiceAudioBridge({
+ session,
+ sendCommand: (command) => commands.push(command),
+ });
+
+ return {
+ bridge,
+ session,
+ stt,
+ commands,
+ kinds: () => commands.map((command) => command.kind),
+ push: (samples, count = 1) => {
+ for (let i = 0; i < count; i++) bridge.handleFrame(frame(samples));
+ },
+ };
+}
+
+/** Boot the host and open the floor, then forget the commands that took. */
+function ready(h: Harness): void {
+ h.bridge.handleStatus({ kind: 'ready' });
+ h.session.listen();
+ h.bridge.handleStatus({
+ kind: 'capture-start',
+ device: { deviceId: 'default', label: 'Built-in Microphone' },
+ contextSampleRate: 48_000,
+ });
+ h.commands.length = 0;
+ h.session.micStates.length = 0;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+
+describe('VoiceAudioBridge capture lifecycle', () => {
+ it('opens the microphone when the floor opens', () => {
+ const h = harness();
+ h.bridge.handleStatus({ kind: 'ready' });
+
+ h.session.listen();
+
+ expect(h.kinds()).toContain('start-capture');
+ });
+
+ it('never opens a device for a recogniser that cannot hear', () => {
+ const h = harness({ acceptsAudio: false });
+ h.bridge.handleStatus({ kind: 'ready' });
+
+ h.session.listen();
+
+ // The mock tier is text-in by construction. A permission prompt on its behalf
+ // buys a level meter over a transcript that is never coming.
+ expect(h.kinds()).not.toContain('start-capture');
+ });
+
+ it('closes the microphone when the session ends', () => {
+ const h = harness();
+ ready(h);
+
+ h.session.state = 'idle';
+ h.session.emit({ type: 'listen-stop', reason: 'stopped' });
+
+ expect(h.kinds()).toEqual(['stop-capture', 'flush']);
+ });
+
+ it('re-requests capture when the host renderer boots after the session did', () => {
+ const h = harness();
+
+ // The window is created on the first session start, so the renderer is still
+ // loading its bundle when the floor opens: the first request is lost.
+ h.session.listen();
+ h.commands.length = 0;
+
+ h.bridge.handleStatus({ kind: 'ready' });
+
+ expect(h.kinds()).toEqual(['start-capture']);
+ });
+
+ it('does not request capture on a ready that no session is waiting for', () => {
+ const h = harness();
+
+ h.bridge.handleStatus({ kind: 'ready' });
+
+ expect(h.commands).toEqual([]);
+ });
+});
+
+describe('VoiceAudioBridge frame routing', () => {
+ it('feeds captured audio to the recogniser while listening', () => {
+ const h = harness();
+ ready(h);
+
+ h.push(tone(), 5);
+
+ expect(h.stt.fed.length).toBeGreaterThan(0);
+ expect(h.bridge.getStats().framesReceived).toBe(5);
+ });
+
+ it('counts frames that arrive with nowhere to go rather than queueing them', () => {
+ const h = harness();
+ ready(h);
+ h.session.state = 'dispatching';
+
+ h.push(tone(), 30);
+
+ expect(h.stt.fed).toEqual([]);
+ expect(h.bridge.getStats().framesDropped).toBe(30);
+ });
+
+ it('ignores frames once disposed', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.dispose();
+ h.push(tone(), 5);
+
+ expect(h.stt.fed).toEqual([]);
+ });
+});
+
+describe('VoiceAudioBridge level meter', () => {
+ it('publishes a downsampled level rather than one update per frame', () => {
+ const h = harness();
+ ready(h);
+
+ h.push(tone(), 30);
+
+ // The meter's window is what decides the rate; the bridge only publishes what
+ // comes back out of it.
+ expect(h.session.levels.length).toBeGreaterThan(0);
+ expect(h.session.levels.length).toBeLessThan(30);
+ expect(h.session.levels.at(-1)!.level).toBeGreaterThan(0);
+ });
+
+ it('marks the windows the detector held the floor open for', () => {
+ const h = harness();
+ ready(h);
+
+ h.push(tone(), 30);
+
+ // A meter that shows a loud room and one that shows a person talking are
+ // different things, and only this flag can tell them apart.
+ expect(h.session.levels.some((update) => update.speech)).toBe(true);
+ });
+
+ it('stops republishing an unchanging silence', () => {
+ const h = harness();
+ ready(h);
+
+ h.push(silence(), 60);
+
+ // An open microphone in a quiet room is most of a voice session, not an edge
+ // case: the meter falls to zero once and then says nothing.
+ expect(h.session.levels).toHaveLength(1);
+ expect(h.session.levels[0].level).toBe(0);
+ });
+});
+
+describe('VoiceAudioBridge microphone state', () => {
+ it('publishes the device once capture proves the permission', () => {
+ const h = harness();
+ h.bridge.handleStatus({ kind: 'ready' });
+ h.session.listen();
+
+ h.bridge.handleStatus({
+ kind: 'capture-start',
+ device: { deviceId: 'default', label: 'Built-in Microphone' },
+ contextSampleRate: 48_000,
+ });
+
+ expect(h.session.micStates.at(-1)).toMatchObject({
+ permission: 'granted',
+ capturing: true,
+ deviceLabel: 'Built-in Microphone',
+ });
+ });
+
+ it('turns a capture failure into both a microphone state and a session error', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleStatus({
+ kind: 'mic-error',
+ code: 'permission-denied',
+ message: 'Permission denied',
+ });
+
+ // A dead microphone must never present as a session that is merely quiet, so
+ // it produces the state a HUD draws AND the error a client can act on.
+ expect(h.session.micStates.at(-1)).toMatchObject({
+ permission: 'denied',
+ issue: 'permission-denied',
+ capturing: false,
+ });
+ expect(h.session.failures).toEqual([
+ { code: 'permission-denied', message: 'Permission denied' },
+ ]);
+ });
+
+ it('leaves the meter at rest when the device goes away', () => {
+ const h = harness();
+ ready(h);
+ h.push(tone(), 30);
+ h.session.levels.length = 0;
+
+ h.bridge.handleStatus({ kind: 'capture-stop', reason: 'device-lost' });
+ h.session.state = 'listening';
+ h.push(silence(), 10);
+
+ // A bar left standing over a device nobody is reading is the same lie as a
+ // listening indicator over a denied one, so the next run republishes from
+ // scratch instead of inheriting the last level.
+ expect(h.session.levels.length).toBeGreaterThan(0);
+ expect(h.session.levels[0].level).toBe(0);
+ });
+
+ it('says nothing about statuses that say nothing about the microphone', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleStatus({
+ kind: 'playback-state',
+ playing: true,
+ utteranceId: 'u1',
+ queuedMs: 40,
+ });
+
+ expect(h.session.micStates).toEqual([]);
+ });
+});
+
+describe('VoiceAudioBridge barge-in', () => {
+ it('cuts the assistant off when the user talks over it', () => {
+ const h = harness();
+ ready(h);
+ h.session.state = 'speaking';
+
+ h.push(tone(), 12);
+
+ expect(h.session.interrupts).toBe(1);
+ expect(h.kinds()).toContain('flush');
+ });
+
+ it('ducks on suspicion before it is sure', () => {
+ const h = harness();
+ ready(h);
+ h.session.state = 'speaking';
+
+ h.push(tone(), 1);
+
+ // 80 ms before a `speech-start` can be confirmed, the user hears themselves
+ // win the room.
+ expect(h.commands[0]).toMatchObject({ kind: 'duck' });
+ expect(h.session.interrupts).toBe(0);
+ });
+});
+
+describe('VoiceAudioBridge playback', () => {
+ function chunk(overrides: Partial = {}): TtsChunk {
+ return {
+ utteranceId: 'u1',
+ index: 0,
+ text: 'All done.',
+ format: 'pcm16',
+ sampleRate: 24_000,
+ audio: new Uint8Array([1, 2, 3, 4]),
+ ...overrides,
+ };
+ }
+
+ it('plays a chunk through the same host that captures', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleSpeechChunk(chunk());
+
+ expect(h.commands).toEqual([
+ expect.objectContaining({
+ kind: 'play',
+ utteranceId: 'u1',
+ format: 'pcm16',
+ sampleRate: 24_000,
+ }),
+ ]);
+ const command = h.commands[0] as Extract;
+ expect(new Uint8Array(command.data)).toEqual(new Uint8Array([1, 2, 3, 4]));
+ });
+
+ it('sends a container format on to be decoded', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleSpeechChunk(chunk({ format: 'mp3', sampleRate: undefined }));
+
+ expect(h.commands[0]).toMatchObject({ kind: 'play', format: 'encoded' });
+ });
+
+ it('drops a silent chunk instead of playing nothing', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleSpeechChunk(chunk({ format: 'none', audio: null }));
+
+ expect(h.commands).toEqual([]);
+ });
+
+ it('refuses raw samples with no sample rate rather than guessing one', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.handleSpeechChunk(chunk({ sampleRate: undefined }));
+
+ // Guessing is how a voice ends up an octave out.
+ expect(h.commands).toEqual([]);
+ });
+
+ it('drains the queue when a speech run finishes on its own', () => {
+ const h = harness();
+ ready(h);
+
+ h.session.emit({ type: 'speak-end', utteranceId: 'u1', reason: 'complete' });
+
+ expect(h.commands).toEqual([{ kind: 'end-utterance', utteranceId: 'u1' }]);
+ });
+
+ it('throws the queue away when a run was cut off', () => {
+ const h = harness();
+ ready(h);
+
+ h.session.emit({ type: 'speak-end', utteranceId: 'u1', reason: 'cancelled' });
+
+ // What is queued is audio the user has already talked over.
+ expect(h.commands).toEqual([{ kind: 'flush' }]);
+ });
+
+ it('drops playback aimed at a host that has not booted yet', () => {
+ const h = harness();
+ h.session.listen();
+ h.commands.length = 0;
+
+ h.bridge.handleSpeechChunk(chunk());
+
+ expect(h.commands).toEqual([]);
+ });
+});
+
+describe('VoiceAudioBridge endpointing', () => {
+ it('forces the recogniser to endpoint on demand', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.endUtterance();
+
+ expect(h.stt.flushes).toBe(1);
+ });
+
+ it('is a no-op when there is no session to endpoint', () => {
+ const h = harness();
+
+ expect(() => h.bridge.endUtterance()).not.toThrow();
+ expect(h.stt.flushes).toBe(0);
+ });
+
+ it('survives a recogniser that cannot take the hint', async () => {
+ const h = harness();
+ ready(h);
+ h.stt.flushError = new Error('no endpointing here');
+
+ h.bridge.endUtterance();
+ await Promise.resolve();
+
+ // Endpointing is a hint; the recogniser still has the audio.
+ expect(h.stt.flushes).toBe(1);
+ });
+});
+
+describe('VoiceAudioBridge teardown', () => {
+ it('closes the microphone on dispose rather than after it', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.dispose();
+
+ // The stop command has to leave BEFORE the bridge marks itself dead, or the
+ // device stays open with nothing left to close it.
+ expect(h.kinds()).toContain('stop-capture');
+ });
+
+ it('is safe to dispose twice', () => {
+ const h = harness();
+ ready(h);
+
+ h.bridge.dispose();
+ h.commands.length = 0;
+ h.bridge.dispose();
+
+ expect(h.commands).toEqual([]);
+ });
+
+ it('stops following the session it was disposed for', () => {
+ const h = harness();
+ ready(h);
+ h.bridge.dispose();
+ h.commands.length = 0;
+
+ h.session.listen();
+
+ expect(h.commands).toEqual([]);
+ });
+});
+
+describe('VoiceAudioBridge', () => {
+ it('is constructible through its factory', () => {
+ const h = harness();
+ expect(h.bridge).toBeInstanceOf(VoiceAudioBridge);
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/audio-pipeline.test.ts b/src/__tests__/main/acappella/audio/audio-pipeline.test.ts
new file mode 100644
index 0000000000..afdb1b5136
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/audio-pipeline.test.ts
@@ -0,0 +1,627 @@
+/**
+ * @file audio-pipeline.test.ts
+ *
+ * The duplex audio pipeline: what reaches the recogniser, what is dropped, what
+ * is held in the pre-roll, and what happens when the user talks over the
+ * assistant.
+ *
+ * Everything here is generated PCM against a fake session and a fake recogniser.
+ * No `AudioContext`, no audio host window, no timers - the pipeline is injected
+ * with its three seams (session, provider, command sink) precisely so a test can
+ * drive a full barge-in in a few microseconds.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { captureException } from '../../../../main/utils/sentry';
+import {
+ AudioFrameRing,
+ AudioPipeline,
+ DEFAULT_PRE_ROLL_MS,
+ MAX_PRE_ROLL_MS,
+ createAudioPipeline,
+ type AudioPipelineOptions,
+ type AudioPipelineSession,
+} from '../../../../main/acappella/audio/audio-pipeline';
+import {
+ ACAPPELLA_AUDIO_FRAME_MS,
+ ACAPPELLA_AUDIO_FRAME_SAMPLES,
+ ACAPPELLA_AUDIO_SAMPLE_RATE,
+ type AudioFrame,
+ type AudioHostCommand,
+} from '../../../../shared/acappella/audio-host';
+import type { SttCallbacks, SttProvider } from '../../../../shared/acappella/providers';
+import type { VoiceSessionState } from '../../../../shared/acappella/session-state';
+
+// ---------------------------------------------------------------------------
+// Signal generators
+// ---------------------------------------------------------------------------
+
+function build(fill: (index: number) => number): Int16Array {
+ const samples = new Int16Array(ACAPPELLA_AUDIO_FRAME_SAMPLES);
+ for (let i = 0; i < ACAPPELLA_AUDIO_FRAME_SAMPLES; i++) {
+ const value = Math.max(-1, Math.min(1, fill(i)));
+ samples[i] = value < 0 ? value * 0x8000 : value * 0x7fff;
+ }
+ return samples;
+}
+
+const silence = (): Int16Array => build(() => 0);
+
+/** Voiced speech stand-in: 200 Hz sits squarely inside the VAD's zero-crossing band. */
+const tone = (amplitude = 0.4): Int16Array =>
+ build((i) => amplitude * Math.sin((2 * Math.PI * 200 * i) / ACAPPELLA_AUDIO_SAMPLE_RATE));
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+class FakeStt implements SttProvider {
+ readonly id = 'fake-stt';
+ readonly label = 'Fake STT';
+ readonly tier = 'mock' as const;
+ readonly sampleRate = ACAPPELLA_AUDIO_SAMPLE_RATE;
+ readonly acceptsAudio = true;
+
+ readonly fed: Int16Array[] = [];
+ flushes = 0;
+ feedError: Error | null = null;
+ flushError: Error | null = null;
+
+ async start(_callbacks: SttCallbacks): Promise {}
+
+ feed(pcm: Int16Array): void {
+ if (this.feedError) throw this.feedError;
+ this.fed.push(pcm);
+ }
+
+ async flush(): Promise {
+ this.flushes += 1;
+ if (this.flushError) throw this.flushError;
+ }
+
+ async stop(): Promise {}
+}
+
+class FakeSession implements AudioPipelineSession {
+ state: VoiceSessionState = 'idle';
+ interrupts = 0;
+
+ getState(): VoiceSessionState {
+ return this.state;
+ }
+
+ /** Mirrors the real service: barge-in only means something while speaking. */
+ interrupt(): boolean {
+ if (this.state !== 'speaking') return false;
+ this.interrupts += 1;
+ this.state = 'listening';
+ return true;
+ }
+}
+
+interface Harness {
+ pipeline: AudioPipeline;
+ session: FakeSession;
+ stt: FakeStt;
+ commands: AudioHostCommand[];
+ bargeIns: number;
+ /** Push `count` copies of one frame through the pipeline. */
+ push(samples: Int16Array, count?: number): void;
+}
+
+function harness(overrides: Partial = {}): Harness {
+ const session = new FakeSession();
+ const stt = new FakeStt();
+ const commands: AudioHostCommand[] = [];
+ let seq = 0;
+
+ const state = { bargeIns: 0 };
+ const pipeline = createAudioPipeline({
+ session,
+ getStt: () => stt,
+ sendCommand: (command) => commands.push(command),
+ onBargeIn: () => {
+ state.bargeIns += 1;
+ },
+ ...overrides,
+ });
+
+ return {
+ pipeline,
+ session,
+ stt,
+ commands,
+ get bargeIns() {
+ return state.bargeIns;
+ },
+ push(samples, count = 1) {
+ for (let i = 0; i < count; i++) {
+ seq += 1;
+ pipeline.handleFrame(frameOf(samples, seq));
+ }
+ },
+ };
+}
+
+function frameOf(samples: Int16Array, seq: number): AudioFrame {
+ // A copy per frame, because the pipeline keeps references in the pre-roll and a
+ // shared buffer would make every buffered frame the last one pushed.
+ const copy = new Int16Array(samples);
+ return {
+ seq,
+ capturedAt: 1_700_000_000_000 + seq * ACAPPELLA_AUDIO_FRAME_MS,
+ rms: 0,
+ pcm: copy.buffer,
+ };
+}
+
+function commandsOfKind(
+ commands: AudioHostCommand[],
+ kind: K
+): Extract[] {
+ return commands.filter((c): c is Extract => c.kind === kind);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+
+describe('AudioFrameRing', () => {
+ it('evicts the oldest frame once it is full', () => {
+ const ring = new AudioFrameRing(2);
+ const first = new Int16Array([1]);
+ const second = new Int16Array([2]);
+ const third = new Int16Array([3]);
+ ring.push(first);
+ ring.push(second);
+ ring.push(third);
+
+ expect(ring.size).toBe(2);
+ expect(ring.drain()).toEqual([second, third]);
+ expect(ring.size).toBe(0);
+ });
+
+ it('holds nothing at zero capacity', () => {
+ const ring = new AudioFrameRing(0);
+ ring.push(new Int16Array([1]));
+ expect(ring.size).toBe(0);
+ });
+});
+
+describe('capture lifecycle', () => {
+ it('opens the microphone on start and closes it on stop', () => {
+ const h = harness();
+ h.pipeline.start();
+ expect(h.commands).toEqual([{ kind: 'start-capture' }]);
+
+ h.pipeline.stop();
+ // The flush is not optional: a session that ends mid-sentence must not keep
+ // talking into a room whose microphone it just released.
+ expect(h.commands).toEqual([
+ { kind: 'start-capture' },
+ { kind: 'stop-capture' },
+ { kind: 'flush' },
+ ]);
+ });
+
+ it('is idempotent in both directions', () => {
+ const h = harness();
+ h.pipeline.start();
+ h.pipeline.start();
+ h.pipeline.stop();
+ h.pipeline.stop();
+ expect(commandsOfKind(h.commands, 'start-capture')).toHaveLength(1);
+ expect(commandsOfKind(h.commands, 'stop-capture')).toHaveLength(1);
+ });
+
+ it('ignores frames that arrive before start or after stop', () => {
+ const h = harness();
+ h.session.state = 'listening';
+
+ h.push(tone(), 3);
+ expect(h.stt.fed).toHaveLength(0);
+
+ h.pipeline.start();
+ h.push(tone(), 3);
+ expect(h.stt.fed).toHaveLength(3);
+
+ h.pipeline.stop();
+ h.push(tone(), 3);
+ expect(h.stt.fed).toHaveLength(3);
+ });
+
+ it('clears the counters between runs', () => {
+ const h = harness();
+ h.pipeline.start();
+ h.push(tone(), 5);
+ expect(h.pipeline.getStats().framesReceived).toBe(5);
+
+ h.pipeline.stop();
+ h.pipeline.start();
+ expect(h.pipeline.getStats().framesReceived).toBe(0);
+ });
+});
+
+describe('routing frames to the recogniser', () => {
+ it('feeds every frame while the session is listening', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.push(tone(), 10);
+
+ expect(h.stt.fed).toHaveLength(10);
+ expect(h.pipeline.getStats()).toMatchObject({
+ framesReceived: 10,
+ framesDelivered: 10,
+ framesDropped: 0,
+ });
+ });
+
+ it('drops frames rather than queueing them when the session is not listening', () => {
+ const h = harness();
+ h.session.state = 'dispatching';
+ h.pipeline.start();
+
+ h.push(tone(), 200);
+
+ expect(h.stt.fed).toHaveLength(0);
+ expect(h.pipeline.getStats().framesDropped).toBe(200);
+ });
+
+ it('drops frames when no recogniser is running', () => {
+ const h = harness({ getStt: () => null });
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.push(tone(), 4);
+
+ expect(h.pipeline.getStats()).toMatchObject({ framesDelivered: 0, framesDropped: 4 });
+ });
+
+ it('forwards the VAD endpoint to the recogniser as a flush', () => {
+ const h = harness({ vad: { adaptiveNoiseFloor: false, endpointSilenceMs: 100 } });
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.push(tone(), 6);
+ expect(h.stt.flushes).toBe(0);
+
+ // 100 ms of silence is five frames; the fifth endpoints.
+ h.push(silence(), 5);
+ expect(h.stt.flushes).toBe(1);
+ });
+
+ it('counts frames the transport lost instead of hiding the gap', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.pipeline.handleFrame(frameOf(tone(), 1));
+ h.pipeline.handleFrame(frameOf(tone(), 5));
+
+ expect(h.pipeline.getStats().sequenceGaps).toBe(3);
+ });
+});
+
+describe('pre-roll', () => {
+ const preRollFrames = DEFAULT_PRE_ROLL_MS / ACAPPELLA_AUDIO_FRAME_MS;
+
+ it('sizes itself from the configured window', () => {
+ expect(harness().pipeline.preRollCapacity).toBe(preRollFrames);
+ expect(harness({ preRollMs: 200 }).pipeline.preRollCapacity).toBe(10);
+ expect(harness({ preRollMs: 0 }).pipeline.preRollCapacity).toBe(0);
+ });
+
+ it('clamps an absurd window rather than trusting the setting', () => {
+ expect(harness({ preRollMs: 60_000 }).pipeline.preRollCapacity).toBe(
+ MAX_PRE_ROLL_MS / ACAPPELLA_AUDIO_FRAME_MS
+ );
+ expect(harness({ preRollMs: Number.NaN }).pipeline.preRollCapacity).toBe(preRollFrames);
+ });
+
+ it('replays the audio spoken just before the floor opened', () => {
+ const h = harness();
+ h.pipeline.start();
+
+ // The user starts talking before the wake word has opened the session.
+ h.push(tone(), 3);
+ expect(h.stt.fed).toHaveLength(0);
+
+ h.session.state = 'listening';
+ h.push(tone(), 1);
+
+ // Three pre-roll frames plus the live one: the first syllable survives.
+ expect(h.stt.fed).toHaveLength(4);
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBe(3);
+ });
+
+ it('is bounded: a long idle stretch replays only the last window', () => {
+ const h = harness();
+ h.pipeline.start();
+
+ h.push(tone(), 1000);
+ h.session.state = 'listening';
+ h.push(tone(), 1);
+
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBe(preRollFrames);
+ expect(h.stt.fed).toHaveLength(preRollFrames + 1);
+ expect(h.pipeline.getStats().framesDropped).toBe(1000);
+ });
+
+ it('does not re-deliver audio the recogniser already has', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.push(tone(), 5);
+ h.session.state = 'dispatching';
+ h.push(silence(), 2);
+ h.session.state = 'listening';
+ h.push(tone(), 1);
+
+ // Five live frames, the two dropped ones from the pre-roll, one more live.
+ expect(h.stt.fed).toHaveLength(8);
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBe(2);
+ });
+
+ it('drops the pre-roll when the capture device goes away', () => {
+ const h = harness();
+ h.pipeline.start();
+ h.push(tone(), 5);
+
+ h.pipeline.handleStatus({ kind: 'mic-error', code: 'device-lost', message: 'gone' });
+ h.session.state = 'listening';
+ h.push(tone(), 1);
+
+ // Audio from a device that no longer exists is not context, it is confusion.
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBe(0);
+ expect(h.stt.fed).toHaveLength(1);
+ });
+
+ it('keeps the pre-roll when a device is merely plugged in', () => {
+ const h = harness();
+ h.pipeline.start();
+ h.push(tone(), 5);
+
+ // `device-change` says the device SET moved, not that ours did. Plugging in
+ // headphones mid-sentence must not throw away the words already spoken.
+ h.pipeline.handleStatus({ kind: 'device-change' });
+ h.pipeline.handleStatus({ kind: 'ready' });
+ h.pipeline.handleStatus({
+ kind: 'playback-state',
+ playing: false,
+ utteranceId: null,
+ queuedMs: 0,
+ });
+ h.session.state = 'listening';
+ h.push(tone(), 1);
+
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBe(5);
+ expect(h.stt.fed).toHaveLength(6);
+ });
+});
+
+describe('barge-in', () => {
+ it('flushes playback, cancels the speech run, and takes the floor back', () => {
+ const h = harness();
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 6);
+
+ expect(commandsOfKind(h.commands, 'flush')).toHaveLength(1);
+ expect(h.session.interrupts).toBe(1);
+ expect(h.session.state).toBe('listening');
+ expect(h.bargeIns).toBe(1);
+ expect(h.pipeline.getStats().bargeIns).toBe(1);
+ });
+
+ it('delivers the audio the user interrupted with', () => {
+ const h = harness();
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 6);
+
+ // The interrupting syllables were in the pre-roll when the floor opened, so
+ // the recogniser hears the whole word rather than its tail.
+ expect(h.stt.fed.length).toBeGreaterThanOrEqual(6);
+ expect(h.pipeline.getStats().preRollFramesDelivered).toBeGreaterThan(0);
+ });
+
+ it('ducks output on suspicion, before the interrupt is confirmed', () => {
+ const h = harness();
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 1);
+
+ // One frame in: too little evidence to cancel a speech run, enough to get out
+ // of the user's way.
+ expect(commandsOfKind(h.commands, 'duck')).toEqual([{ kind: 'duck', gain: 0.2, ms: 60 }]);
+ expect(commandsOfKind(h.commands, 'flush')).toHaveLength(0);
+ expect(h.session.interrupts).toBe(0);
+ });
+
+ it('restores the gain when the suspicion does not become speech', () => {
+ const h = harness();
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 2);
+ h.push(silence(), 2);
+
+ const ducks = commandsOfKind(h.commands, 'duck');
+ expect(ducks).toHaveLength(2);
+ expect(ducks[1]).toEqual({ kind: 'duck', gain: 1, ms: 60 });
+ expect(h.session.interrupts).toBe(0);
+ });
+
+ it('restores the gain when playback ends on its own', () => {
+ const h = harness();
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 1);
+ h.session.state = 'listening';
+ h.push(silence(), 1);
+
+ const ducks = commandsOfKind(h.commands, 'duck');
+ expect(ducks[ducks.length - 1]).toEqual({ kind: 'duck', gain: 1, ms: 60 });
+ });
+
+ it('does not interrupt while the session is listening', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ h.push(tone(), 20);
+
+ expect(commandsOfKind(h.commands, 'flush')).toHaveLength(0);
+ expect(commandsOfKind(h.commands, 'duck')).toHaveLength(0);
+ expect(h.session.interrupts).toBe(0);
+ });
+
+ it('needs sustained speech, not one loud frame', () => {
+ const h = harness({ vad: { adaptiveNoiseFloor: false, enterFrames: 4 } });
+ h.session.state = 'speaking';
+ h.pipeline.start();
+
+ h.push(tone(), 3);
+ expect(h.session.interrupts).toBe(0);
+
+ h.push(tone(), 1);
+ expect(h.session.interrupts).toBe(1);
+ });
+
+ it('starts barge-in detection from a closed floor when playback begins', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+
+ // The user is mid-utterance, so the detector is open when the reply starts.
+ h.push(tone(), 10);
+ h.session.state = 'speaking';
+
+ // A single frame of leftover speech must not read as an interruption; the
+ // evidence has to be gathered again against the assistant's own voice.
+ h.push(tone(), 1);
+ expect(h.session.interrupts).toBe(0);
+ h.push(tone(), 3);
+ expect(h.session.interrupts).toBe(1);
+ });
+
+ it('still flushes when the speech run ended between the frame and the interrupt', () => {
+ const session = new FakeSession();
+ const h = harness({ session });
+ session.state = 'speaking';
+ h.pipeline.start();
+
+ // The service moved on by itself: `interrupt()` reports nothing to cancel.
+ session.interrupt = () => false;
+ h.push(tone(), 6);
+
+ expect(commandsOfKind(h.commands, 'flush')).toHaveLength(1);
+ expect(h.pipeline.getStats().bargeIns).toBe(0);
+ expect(h.bargeIns).toBe(0);
+ });
+});
+
+describe('failure handling', () => {
+ it('counts a throwing feed once and keeps the run alive', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+ h.stt.feedError = new Error('provider died');
+
+ expect(() => h.push(tone(), 50)).not.toThrow();
+
+ expect(h.pipeline.getStats().feedErrors).toBe(50);
+ expect(h.pipeline.getStats().framesDelivered).toBe(0);
+ // 50 identical reports a second is how a Sentry project becomes unreadable.
+ expect(captureException).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports a rejected endpoint without failing the frame', async () => {
+ const h = harness({ vad: { adaptiveNoiseFloor: false, endpointSilenceMs: 100 } });
+ h.session.state = 'listening';
+ h.pipeline.start();
+ h.stt.flushError = new Error('no endpoint');
+
+ h.push(tone(), 6);
+ h.push(silence(), 5);
+ await Promise.resolve();
+
+ expect(captureException).toHaveBeenCalledTimes(1);
+ expect(h.pipeline.getStats().framesDelivered).toBe(11);
+ });
+});
+
+describe('observability', () => {
+ it('reports every frame with its verdict and whether it was delivered', () => {
+ const seen: { delivered: boolean; rms: number }[] = [];
+ const h = harness({
+ onFrame: ({ result, delivered }) => seen.push({ delivered, rms: result.rms }),
+ });
+ h.session.state = 'idle';
+ h.pipeline.start();
+
+ h.push(tone(), 2);
+ h.session.state = 'listening';
+ h.push(tone(), 2);
+
+ expect(seen).toHaveLength(4);
+ expect(seen.map((s) => s.delivered)).toEqual([false, false, true, true]);
+ // The level meter in Phase 02's HUD task reads exactly this.
+ expect(seen[0].rms).toBeGreaterThan(0);
+ });
+
+ it('resets its counters when capture restarts', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+ h.push(tone(), 4);
+
+ h.pipeline.handleStatus({
+ kind: 'capture-start',
+ device: { deviceId: 'default', label: 'Mic' },
+ contextSampleRate: 48_000,
+ });
+
+ expect(h.pipeline.getStats().framesReceived).toBe(0);
+ });
+
+ it('stops and releases everything on dispose', () => {
+ const h = harness();
+ h.session.state = 'listening';
+ h.pipeline.start();
+ h.push(tone(), 4);
+
+ h.pipeline.dispose();
+
+ expect(h.pipeline.isRunning).toBe(false);
+ expect(commandsOfKind(h.commands, 'stop-capture')).toHaveLength(1);
+ });
+});
+
+describe('AudioPipeline construction', () => {
+ it('accepts a partial VAD config and exposes what it resolved', () => {
+ const pipeline = new AudioPipeline({
+ session: new FakeSession(),
+ getStt: () => null,
+ sendCommand: () => {},
+ vad: { endpointSilenceMs: 400 },
+ });
+ expect(pipeline.isRunning).toBe(false);
+ expect(pipeline.preRollCapacity).toBe(DEFAULT_PRE_ROLL_MS / ACAPPELLA_AUDIO_FRAME_MS);
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/floor-control.test.ts b/src/__tests__/main/acappella/audio/floor-control.test.ts
new file mode 100644
index 0000000000..4272e9c830
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/floor-control.test.ts
@@ -0,0 +1,656 @@
+/**
+ * @file floor-control.test.ts
+ *
+ * Who holds the microphone: tap versus hold semantics, the idle timeout, and the
+ * ways the floor can change without anyone pressing anything.
+ *
+ * The controller is injected with a fake session, so every case here runs
+ * without Electron, a hotkey, a phone, or an audio device. Time is faked because
+ * the idle timeout is the one part of A Cappella's audio path that legitimately
+ * uses a wall clock: it is measuring a human who walked away, not audio.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+vi.mock('../../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { captureException } from '../../../../main/utils/sentry';
+import {
+ DEFAULT_FLOOR_MODE,
+ DEFAULT_IDLE_TIMEOUT_MS,
+ FloorController,
+ MAX_IDLE_TIMEOUT_MS,
+ MIN_IDLE_TIMEOUT_MS,
+ createFloorController,
+ resolveFloorControlConfig,
+ type FloorCloseReason,
+ type FloorControlOptions,
+ type FloorControlSession,
+ type FloorOpenReason,
+ type FloorSessionStopReason,
+} from '../../../../main/acappella/audio/floor-control';
+import type { VoiceEvent, VoiceScope, WakeSource } from '../../../../shared/acappella/protocol';
+import type { VoiceSessionState } from '../../../../shared/acappella/session-state';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+class FakeSession implements FloorControlSession {
+ state: VoiceSessionState = 'idle';
+ readonly starts: { scope: VoiceScope; source?: WakeSource }[] = [];
+ readonly stops: FloorSessionStopReason[] = [];
+ interrupts = 0;
+ startError: Error | null = null;
+ stopError: Error | null = null;
+ /** Set by a test to hold `startSession` open until it resolves. */
+ startGate: Promise | null = null;
+
+ getState(): VoiceSessionState {
+ return this.state;
+ }
+
+ async startSession(params: { scope: VoiceScope; source?: WakeSource }): Promise {
+ if (this.startGate) await this.startGate;
+ if (this.startError) throw this.startError;
+ this.starts.push(params);
+ this.state = 'listening';
+ return { state: this.state };
+ }
+
+ async stopSession(reason: FloorSessionStopReason): Promise {
+ this.stops.push(reason);
+ if (this.stopError) throw this.stopError;
+ this.state = 'idle';
+ }
+
+ interrupt(): boolean {
+ if (this.state !== 'speaking') return false;
+ this.interrupts += 1;
+ this.state = 'listening';
+ return true;
+ }
+}
+
+interface Harness {
+ floor: FloorController;
+ session: FakeSession;
+ changes: { open: boolean; reason: FloorOpenReason | FloorCloseReason }[];
+ endUtterances: number;
+ errors: Error[];
+}
+
+function harness(overrides: Partial = {}): Harness {
+ const session = new FakeSession();
+ const changes: Harness['changes'] = [];
+ const errors: Error[] = [];
+ const state = { endUtterances: 0 };
+
+ const floor = createFloorController({
+ session,
+ onFloorChange: (open, reason) => changes.push({ open, reason }),
+ onError: (error) => errors.push(error),
+ endUtterance: () => {
+ state.endUtterances += 1;
+ },
+ ...overrides,
+ });
+
+ return {
+ floor,
+ session,
+ changes,
+ errors,
+ get endUtterances() {
+ return state.endUtterances;
+ },
+ };
+}
+
+/** A protocol event with the envelope filled in. Only `type` matters here. */
+function event(body: Partial & { type: VoiceEvent['type'] }): VoiceEvent {
+ return { sessionId: 'voice-1', seq: 1, ts: 0, ...body } as unknown as VoiceEvent;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+describe('resolveFloorControlConfig', () => {
+ it('defaults to hands-free with the default idle timeout', () => {
+ expect(resolveFloorControlConfig()).toEqual({
+ mode: DEFAULT_FLOOR_MODE,
+ idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
+ });
+ });
+
+ it('clamps an idle timeout that would hang up on an ordinary pause', () => {
+ expect(resolveFloorControlConfig({ idleTimeoutMs: 200 }).idleTimeoutMs).toBe(
+ MIN_IDLE_TIMEOUT_MS
+ );
+ });
+
+ it('clamps an idle timeout that would leave a microphone open all day', () => {
+ expect(resolveFloorControlConfig({ idleTimeoutMs: 60 * 60_000 }).idleTimeoutMs).toBe(
+ MAX_IDLE_TIMEOUT_MS
+ );
+ });
+
+ it('treats zero as explicitly disabled rather than clamping it up', () => {
+ expect(resolveFloorControlConfig({ idleTimeoutMs: 0 }).idleTimeoutMs).toBe(0);
+ });
+
+ it('falls back to the default for a nonsense timeout', () => {
+ expect(resolveFloorControlConfig({ idleTimeoutMs: Number.NaN }).idleTimeoutMs).toBe(
+ DEFAULT_IDLE_TIMEOUT_MS
+ );
+ expect(resolveFloorControlConfig({ idleTimeoutMs: -5 }).idleTimeoutMs).toBe(
+ DEFAULT_IDLE_TIMEOUT_MS
+ );
+ });
+
+ it('falls back to the default for an unknown mode', () => {
+ expect(resolveFloorControlConfig({ mode: 'push' as never }).mode).toBe(DEFAULT_FLOOR_MODE);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tap to toggle
+// ---------------------------------------------------------------------------
+
+describe('tap-to-toggle', () => {
+ it('opens a conductor-scoped session on the first press', async () => {
+ const h = harness();
+
+ await h.floor.press('hotkey');
+
+ expect(h.session.starts).toEqual([{ scope: { kind: 'conductor' }, source: 'hotkey' }]);
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.changes).toEqual([{ open: true, reason: 'press' }]);
+ });
+
+ it('opens the scope the caller supplies', async () => {
+ const scope: VoiceScope = { kind: 'agent', sessionId: 'agent-7' };
+ const h = harness({ getScope: () => scope });
+
+ await h.floor.press();
+
+ expect(h.session.starts[0].scope).toEqual(scope);
+ });
+
+ it('closes the session on the next press', async () => {
+ const h = harness();
+
+ await h.floor.press();
+ await h.floor.release();
+ await h.floor.press();
+
+ expect(h.session.stops).toEqual(['user']);
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.changes.at(-1)).toEqual({ open: false, reason: 'toggle' });
+ });
+
+ it('ignores a held key repeating, so a repeat cannot toggle the floor', async () => {
+ const h = harness();
+
+ await h.floor.press();
+ await h.floor.press();
+ await h.floor.press();
+
+ expect(h.session.starts).toHaveLength(1);
+ expect(h.session.stops).toEqual([]);
+ expect(h.floor.isFloorOpen).toBe(true);
+ });
+
+ it('ignores a release: a tap and a long press are the same gesture', async () => {
+ const h = harness();
+
+ await h.floor.press();
+ await h.floor.release();
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.session.stops).toEqual([]);
+ expect(h.endUtterances).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Hold to talk
+// ---------------------------------------------------------------------------
+
+describe('hold-to-talk', () => {
+ it('opens the floor on press', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+
+ await h.floor.press('hotkey');
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.floor.isHeld).toBe(true);
+ expect(h.session.starts).toHaveLength(1);
+ });
+
+ it('endpoints the utterance on release, bypassing VAD silence', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+
+ await h.floor.press();
+ await h.floor.release();
+
+ expect(h.endUtterances).toBe(1);
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.changes.at(-1)).toEqual({ open: false, reason: 'release' });
+ });
+
+ it('keeps the session alive after release so the reply can arrive', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+
+ await h.floor.press();
+ await h.floor.release();
+
+ expect(h.session.stops).toEqual([]);
+ expect(h.session.state).toBe('listening');
+ });
+
+ it('ignores a release with no matching press', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+
+ await h.floor.release();
+
+ expect(h.endUtterances).toBe(0);
+ expect(h.changes).toEqual([]);
+ });
+
+ it('serialises a release that lands while the session is still starting', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+ let openTheGate!: () => void;
+ h.session.startGate = new Promise((resolve) => {
+ openTheGate = resolve;
+ });
+
+ const pressed = h.floor.press();
+ const released = h.floor.release();
+ await Promise.resolve();
+ // The release cannot be allowed to run against a session that does not exist
+ // yet, so it waits behind the press rather than seeing a closed floor.
+ expect(h.endUtterances).toBe(0);
+
+ openTheGate();
+ await pressed;
+ await released;
+
+ expect(h.session.starts).toHaveLength(1);
+ expect(h.endUtterances).toBe(1);
+ expect(h.floor.isFloorOpen).toBe(false);
+ });
+
+ it('closes the floor even when endpointing fails', async () => {
+ const error = new Error('provider gone');
+ const h = harness({
+ mode: 'hold-to-talk',
+ endUtterance: () => {
+ throw error;
+ },
+ });
+
+ await h.floor.press();
+ await h.floor.release();
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.errors).toEqual([error]);
+ expect(captureException).toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Interruption
+// ---------------------------------------------------------------------------
+
+describe('press over active speech', () => {
+ it('interrupts rather than ending the session', async () => {
+ const h = harness();
+ await h.floor.press();
+ await h.floor.release();
+ h.session.state = 'speaking';
+ h.floor.handleEvent(event({ type: 'speak-start' }));
+
+ await h.floor.press();
+
+ expect(h.session.interrupts).toBe(1);
+ expect(h.session.stops).toEqual([]);
+ expect(h.floor.isFloorOpen).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Idle timeout
+// ---------------------------------------------------------------------------
+
+describe('idle timeout', () => {
+ it('closes a listening session that hears nothing', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+
+ await vi.advanceTimersByTimeAsync(10_000);
+ await h.floor.whenSettled();
+
+ expect(h.session.stops).toEqual(['timeout']);
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.changes.at(-1)).toEqual({ open: false, reason: 'idle-timeout' });
+ });
+
+ it('restarts on speech so a request in progress is never cut off', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+
+ await vi.advanceTimersByTimeAsync(9_000);
+ h.floor.noteActivity();
+ await vi.advanceTimersByTimeAsync(9_000);
+
+ expect(h.session.stops).toEqual([]);
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ await h.floor.whenSettled();
+ expect(h.session.stops).toEqual(['timeout']);
+ });
+
+ it('does not run while the session is working on the user behalf', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+
+ h.session.state = 'dispatching';
+ h.floor.handleEvent(event({ type: 'dispatch' }));
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ expect(h.session.stops).toEqual([]);
+ expect(h.floor.isFloorOpen).toBe(true);
+ });
+
+ it('rearms when the floor comes back to listening', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+ h.session.state = 'speaking';
+ h.floor.handleEvent(event({ type: 'speak-start' }));
+ await vi.advanceTimersByTimeAsync(60_000);
+
+ h.session.state = 'listening';
+ h.floor.handleEvent(event({ type: 'listen-start' }));
+ await vi.advanceTimersByTimeAsync(10_000);
+ await h.floor.whenSettled();
+
+ expect(h.session.stops).toEqual(['timeout']);
+ });
+
+ it('closes a hold-to-talk session whose key was already released', async () => {
+ const h = harness({ mode: 'hold-to-talk', idleTimeoutMs: 10_000 });
+ await h.floor.press();
+ await h.floor.release();
+
+ await vi.advanceTimersByTimeAsync(10_000);
+ await h.floor.whenSettled();
+
+ expect(h.session.stops).toEqual(['timeout']);
+ });
+
+ it('is disabled by a zero timeout', async () => {
+ const h = harness({ idleTimeoutMs: 0 });
+ await h.floor.press();
+
+ await vi.advanceTimersByTimeAsync(MAX_IDLE_TIMEOUT_MS);
+
+ expect(h.session.stops).toEqual([]);
+ expect(h.floor.isFloorOpen).toBe(true);
+ });
+
+ it('is not kept alive by the app talking to itself', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+
+ // A roster push every few seconds is background noise, not a human in the
+ // room: it must not hold the floor open indefinitely.
+ for (let i = 0; i < 5; i++) {
+ await vi.advanceTimersByTimeAsync(2_000);
+ h.floor.handleEvent(event({ type: 'agent-roster', agents: [] }));
+ }
+ await h.floor.whenSettled();
+
+ expect(h.session.stops).toEqual(['timeout']);
+ });
+
+ it('is restarted by a transcript', async () => {
+ const h = harness({ idleTimeoutMs: 10_000 });
+ await h.floor.press();
+
+ await vi.advanceTimersByTimeAsync(9_000);
+ h.floor.handleEvent(event({ type: 'partial-transcript', text: 'hey', stability: 0.4 }));
+ await vi.advanceTimersByTimeAsync(9_000);
+
+ expect(h.session.stops).toEqual([]);
+ });
+
+ it('applies a new timeout immediately rather than after the armed one', async () => {
+ const h = harness({ idleTimeoutMs: 60_000 });
+ await h.floor.press();
+
+ h.floor.configure({ idleTimeoutMs: 10_000 });
+ await vi.advanceTimersByTimeAsync(10_000);
+ await h.floor.whenSettled();
+
+ expect(h.session.stops).toEqual(['timeout']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Following the session
+// ---------------------------------------------------------------------------
+
+describe('session events', () => {
+ it('adopts a floor opened by the wake word', () => {
+ const h = harness();
+ h.session.state = 'listening';
+
+ h.floor.handleEvent(event({ type: 'listen-start' }));
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.changes).toEqual([{ open: true, reason: 'session-started' }]);
+ });
+
+ it('releases the floor when the stop word ends the session', async () => {
+ const h = harness();
+ await h.floor.press();
+
+ h.session.state = 'idle';
+ h.floor.handleEvent(event({ type: 'listen-stop', reason: 'stopped' }));
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.floor.isHeld).toBe(false);
+ // The session ended on its own; closing it again would be a second stop.
+ expect(h.session.stops).toEqual([]);
+ });
+
+ it('releases the floor on an unrecoverable failure', async () => {
+ const h = harness();
+ await h.floor.press();
+
+ h.session.state = 'error';
+ h.floor.handleEvent(
+ event({
+ type: 'session-error',
+ code: 'provider-unavailable',
+ message: 'no stt',
+ recoverable: false,
+ })
+ );
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ });
+
+ it('keeps the floor through a recoverable failure', async () => {
+ const h = harness();
+ await h.floor.press();
+
+ h.floor.handleEvent(
+ event({
+ type: 'session-error',
+ code: 'no-agent-matched',
+ message: 'nobody home',
+ recoverable: true,
+ })
+ );
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Mode changes, failures, shutdown
+// ---------------------------------------------------------------------------
+
+describe('configure', () => {
+ it('keeps an open floor when switching to tap while held', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+ await h.floor.press();
+
+ h.floor.configure({ mode: 'tap-to-toggle' });
+ await h.floor.release();
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.endUtterances).toBe(0);
+ });
+
+ it('reports the resolved mode and timeout', () => {
+ const h = harness();
+
+ h.floor.configure({ mode: 'hold-to-talk', idleTimeoutMs: 1 });
+
+ expect(h.floor.mode).toBe('hold-to-talk');
+ expect(h.floor.idleTimeoutMs).toBe(MIN_IDLE_TIMEOUT_MS);
+ });
+});
+
+describe('failures', () => {
+ it('leaves the floor closed when the session cannot start', async () => {
+ const h = harness();
+ h.session.startError = new Error('no provider');
+
+ await h.floor.press();
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.floor.isHeld).toBe(false);
+ expect(h.errors).toHaveLength(1);
+ expect(captureException).toHaveBeenCalled();
+ });
+
+ it('accepts a press after a failed start rather than wedging', async () => {
+ const h = harness();
+ h.session.startError = new Error('no provider');
+ await h.floor.press();
+
+ h.session.startError = null;
+ await h.floor.press();
+
+ expect(h.floor.isFloorOpen).toBe(true);
+ expect(h.session.starts).toHaveLength(1);
+ });
+
+ it('reports a throwing floor subscriber without abandoning the rest of the action', async () => {
+ const session = new FakeSession();
+ const errors: Error[] = [];
+ const floor = createFloorController({
+ session,
+ onError: (error) => errors.push(error),
+ // The capture gate is this seam and it sends IPC, so a window destroyed
+ // between the press and the notify throws right here.
+ onFloorChange: () => {
+ throw new Error('audio host is gone');
+ },
+ });
+
+ await floor.press();
+ expect(errors).toHaveLength(1);
+ expect(captureException).toHaveBeenCalled();
+ // The session did open; only the notification failed.
+ expect(session.starts).toHaveLength(1);
+
+ await floor.release();
+ await floor.press();
+
+ // The close notify throws too, and the session is still stopped: a listener
+ // that cannot be told must not leave a live session behind a shut floor.
+ expect(errors).toHaveLength(2);
+ expect(session.stops).toEqual(['user']);
+ expect(floor.isFloorOpen).toBe(false);
+ });
+
+ it('reports a failing stop without leaving the floor open', async () => {
+ const h = harness();
+ await h.floor.press();
+ await h.floor.release();
+ h.session.stopError = new Error('teardown exploded');
+
+ await h.floor.press();
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.errors).toHaveLength(1);
+ });
+});
+
+describe('dispose', () => {
+ it('closes the floor and the session', async () => {
+ const h = harness();
+ await h.floor.press();
+
+ await h.floor.dispose();
+
+ expect(h.floor.isFloorOpen).toBe(false);
+ expect(h.session.stops).toEqual(['shutdown']);
+ expect(h.changes.at(-1)).toEqual({ open: false, reason: 'shutdown' });
+ });
+
+ it('stops a hold-to-talk session whose floor was already closed', async () => {
+ const h = harness({ mode: 'hold-to-talk' });
+ await h.floor.press();
+ await h.floor.release();
+
+ await h.floor.dispose();
+
+ expect(h.session.stops).toEqual(['shutdown']);
+ });
+
+ it('ignores input afterwards and is safe to repeat', async () => {
+ const h = harness();
+ await h.floor.press();
+ await h.floor.dispose();
+ await h.floor.dispose();
+
+ await h.floor.press();
+
+ expect(h.session.starts).toHaveLength(1);
+ expect(h.session.stops).toEqual(['shutdown']);
+ expect(h.floor.isFloorOpen).toBe(false);
+ });
+});
+
+describe('createFloorController', () => {
+ it('builds a controller with the resolved config', () => {
+ const controller = createFloorController({
+ session: new FakeSession(),
+ mode: 'hold-to-talk',
+ idleTimeoutMs: 99,
+ });
+
+ expect(controller).toBeInstanceOf(FloorController);
+ expect(controller.mode).toBe('hold-to-talk');
+ expect(controller.idleTimeoutMs).toBe(MIN_IDLE_TIMEOUT_MS);
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/level-meter.test.ts b/src/__tests__/main/acappella/audio/level-meter.test.ts
new file mode 100644
index 0000000000..ffe62bb648
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/level-meter.test.ts
@@ -0,0 +1,178 @@
+/**
+ * @file level-meter.test.ts
+ *
+ * The A Cappella input level meter.
+ *
+ * Contracts defended:
+ * - The window is counted in frames, so the update rate is a property of the
+ * audio quantum rather than of how busy the main thread was.
+ * - What comes out is the true RMS of the window, not a mean of RMSs.
+ * - Speech in any frame of the window survives into the update; a meter that
+ * averaged the flag away could not tell a loud room from a talking person.
+ * - Silence stops republishing itself after the meter has visibly fallen, and a
+ * reset makes the next run publish again.
+ * - Bad configuration is clamped, never thrown: these numbers arrive from user
+ * settings and this runs inside the audio path.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ AudioLevelMeter,
+ DEFAULT_LEVEL_METER_CONFIG,
+ DEFAULT_LEVEL_SILENCE,
+ createAudioLevelMeter,
+ resolveLevelMeterConfig,
+} from '../../../../main/acappella/audio/level-meter';
+import type { AudioLevelUpdate } from '../../../../main/acappella/audio/level-meter';
+
+/** Push `count` frames at one level and collect whatever was published. */
+function push(
+ meter: AudioLevelMeter,
+ count: number,
+ rms: number,
+ speech = false
+): AudioLevelUpdate[] {
+ const updates: AudioLevelUpdate[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const update = meter.push(rms, speech);
+ if (update) updates.push(update);
+ }
+ return updates;
+}
+
+describe('resolveLevelMeterConfig', () => {
+ it('defaults to a 20 ms frame and a 20 Hz target', () => {
+ expect(resolveLevelMeterConfig()).toEqual(DEFAULT_LEVEL_METER_CONFIG);
+ expect(DEFAULT_LEVEL_METER_CONFIG.frameMs).toBe(20);
+ expect(DEFAULT_LEVEL_METER_CONFIG.updateHz).toBe(20);
+ });
+
+ it('falls back rather than throwing on nonsense', () => {
+ const config = resolveLevelMeterConfig({
+ frameMs: 0,
+ updateHz: Number.NaN,
+ silenceLevel: -1,
+ });
+ expect(config.frameMs).toBe(DEFAULT_LEVEL_METER_CONFIG.frameMs);
+ expect(config.updateHz).toBe(DEFAULT_LEVEL_METER_CONFIG.updateHz);
+ expect(config.silenceLevel).toBe(0);
+ });
+});
+
+describe('AudioLevelMeter windowing', () => {
+ it('publishes once per window, not once per frame', () => {
+ const meter = new AudioLevelMeter();
+ expect(meter.windowFrames).toBe(3);
+
+ expect(meter.push(0.1, true)).toBeNull();
+ expect(meter.push(0.1, true)).toBeNull();
+ expect(meter.push(0.1, true)).not.toBeNull();
+ });
+
+ it('lands within a couple of Hz of the 20 per second target', () => {
+ // The frame quantum decides the achievable rate: 2 frames is 25/s and 3 is
+ // 16.7/s, and neither is 20. What must hold is that it is close, and that
+ // the number reported is the realised rate rather than the requested one.
+ const meter = new AudioLevelMeter();
+ expect(meter.updateHz).toBeCloseTo(16.7, 1);
+ expect(Math.abs(meter.updateHz - 20)).toBeLessThan(4);
+ });
+
+ it('never degenerates to zero frames per window on an absurd rate', () => {
+ expect(new AudioLevelMeter({ updateHz: 5000 }).windowFrames).toBe(1);
+ });
+
+ it('honours a wider window when a client asks for fewer updates', () => {
+ const meter = new AudioLevelMeter({ updateHz: 5 });
+ expect(meter.windowFrames).toBe(10);
+ expect(push(meter, 9, 0.1)).toHaveLength(0);
+ expect(push(meter, 1, 0.1)).toHaveLength(1);
+ });
+});
+
+describe('AudioLevelMeter measurement', () => {
+ it('reports the root mean square of the window, not the mean', () => {
+ const meter = new AudioLevelMeter({ updateHz: 1000 / (2 * 20) });
+ expect(meter.windowFrames).toBe(2);
+
+ meter.push(0.3, false);
+ const update = meter.push(0.1, false);
+ // sqrt((0.09 + 0.01) / 2) = 0.2236, above the arithmetic mean of 0.2.
+ expect(update?.level).toBeCloseTo(Math.sqrt(0.05), 6);
+ expect(update?.level).toBeGreaterThan(0.2);
+ });
+
+ it('clamps a frame outside 0 to 1 instead of publishing it', () => {
+ const meter = new AudioLevelMeter({ updateHz: 50 });
+ expect(meter.windowFrames).toBe(1);
+ expect(meter.push(4, false)?.level).toBe(1);
+ expect(meter.push(-1, false)?.level).toBe(0);
+ // Still at rest, so the second silent window says nothing new.
+ expect(meter.push(Number.NaN, false)).toBeNull();
+ });
+
+ it('carries speech out of the window if any frame in it was speech', () => {
+ const meter = new AudioLevelMeter();
+ meter.push(0.001, false);
+ meter.push(0.001, true);
+ expect(meter.push(0.001, false)?.speech).toBe(true);
+ });
+
+ it('starts the next window clean', () => {
+ const meter = new AudioLevelMeter();
+ expect(push(meter, 3, 0.2, true)[0].speech).toBe(true);
+ expect(push(meter, 3, 0.2, false)[0].speech).toBe(false);
+ });
+});
+
+describe('AudioLevelMeter rest suppression', () => {
+ it('publishes the fall to silence once and then goes quiet', () => {
+ const meter = new AudioLevelMeter();
+ expect(push(meter, 3, 0.2)).toHaveLength(1);
+
+ // The first silent window has to reach the client, or the meter freezes at
+ // whatever the last loud frame left on screen.
+ const first = push(meter, 3, 0);
+ expect(first).toHaveLength(1);
+ expect(first[0].level).toBe(0);
+
+ // 60 more windows of an open microphone in a quiet room: nothing to say.
+ expect(push(meter, 180, 0)).toHaveLength(0);
+ });
+
+ it('publishes again the moment the room moves', () => {
+ const meter = new AudioLevelMeter();
+ push(meter, 30, 0);
+ expect(push(meter, 3, 0.2)).toHaveLength(1);
+ });
+
+ it('does not suppress a quiet window the detector called speech', () => {
+ const meter = new AudioLevelMeter();
+ push(meter, 3, 0);
+ push(meter, 3, 0);
+ // Below the silence level, but the floor is open: a whisper still moves the
+ // meter, and a client that stopped hearing updates would show it as closed.
+ expect(push(meter, 3, DEFAULT_LEVEL_SILENCE / 2, true)).toHaveLength(1);
+ });
+
+ it('republishes silence after a reset, so a new run is never born frozen', () => {
+ const meter = createAudioLevelMeter();
+ push(meter, 6, 0);
+ expect(push(meter, 3, 0)).toHaveLength(0);
+
+ meter.reset();
+ expect(push(meter, 3, 0)).toHaveLength(1);
+ });
+
+ it('drops the partial window on reset', () => {
+ const meter = new AudioLevelMeter();
+ meter.push(0.5, false);
+ meter.push(0.5, false);
+ meter.reset();
+
+ expect(meter.push(0.1, false)).toBeNull();
+ expect(meter.push(0.1, false)).toBeNull();
+ // A window built only from the frames pushed after the reset.
+ expect(meter.push(0.1, false)?.level).toBeCloseTo(0.1, 6);
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/mic-state.test.ts b/src/__tests__/main/acappella/audio/mic-state.test.ts
new file mode 100644
index 0000000000..c8cbca4f7e
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/mic-state.test.ts
@@ -0,0 +1,170 @@
+/**
+ * @file mic-state.test.ts
+ *
+ * The A Cappella microphone state projection.
+ *
+ * Contracts defended:
+ * - A live capture is proof of permission, and the device label it carries
+ * outlives the capture run rather than blanking on the next status.
+ * - A denied permission and a missing device are different facts with different
+ * recoveries, so they never collapse into one another.
+ * - Nothing observable changed means nothing is published, except a device
+ * change, which is news even when our own capture is unaffected.
+ * - `deviceChanged` is a flag on an event and never part of the state, so it
+ * cannot stick.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ INITIAL_MIC_STATE,
+ createMicStateTracker,
+} from '../../../../main/acappella/audio/mic-state';
+import type { AudioHostStatus } from '../../../../shared/acappella/audio-host';
+
+const CAPTURE_START: AudioHostStatus = {
+ kind: 'capture-start',
+ device: { deviceId: 'default', label: 'MacBook Pro Microphone' },
+ contextSampleRate: 48000,
+};
+
+describe('MicStateTracker', () => {
+ it('starts knowing nothing rather than assuming the best', () => {
+ const tracker = createMicStateTracker();
+ expect(tracker.state).toEqual(INITIAL_MIC_STATE);
+ expect(tracker.state.permission).toBe('unknown');
+ });
+
+ it('ignores the statuses that say nothing about the microphone', () => {
+ const tracker = createMicStateTracker();
+ expect(tracker.apply({ kind: 'ready' })).toBeNull();
+ expect(
+ tracker.apply({ kind: 'playback-state', playing: true, utteranceId: 'u1', queuedMs: 40 })
+ ).toBeNull();
+ expect(tracker.state).toEqual(INITIAL_MIC_STATE);
+ });
+
+ it('treats a live capture as proof of permission and names the device', () => {
+ const tracker = createMicStateTracker();
+ const state = tracker.apply(CAPTURE_START);
+
+ expect(state).toEqual({
+ permission: 'granted',
+ capturing: true,
+ deviceId: 'default',
+ deviceLabel: 'MacBook Pro Microphone',
+ issue: null,
+ deviceChanged: false,
+ });
+ });
+
+ it('publishes nothing when the same capture is reported twice', () => {
+ const tracker = createMicStateTracker();
+ expect(tracker.apply(CAPTURE_START)).not.toBeNull();
+ expect(tracker.apply(CAPTURE_START)).toBeNull();
+ });
+
+ it('keeps permission and the device name after a requested stop', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+ const state = tracker.apply({ kind: 'capture-stop', reason: 'requested' });
+
+ expect(state?.capturing).toBe(false);
+ expect(state?.issue).toBeNull();
+ // A session ending is not the microphone becoming unknown again.
+ expect(state?.permission).toBe('granted');
+ expect(state?.deviceLabel).toBe('MacBook Pro Microphone');
+ });
+
+ it('reports a device that was taken away as a fault, and forgets it', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+ const state = tracker.apply({ kind: 'capture-stop', reason: 'device-lost' });
+
+ expect(state?.issue).toBe('device-lost');
+ expect(state?.deviceId).toBeNull();
+ expect(state?.deviceLabel).toBeNull();
+ // The user granted access; the microphone is what left.
+ expect(state?.permission).toBe('granted');
+ });
+
+ it('records a denial as a permission fact, not a device fact', () => {
+ const tracker = createMicStateTracker();
+ const state = tracker.apply({
+ kind: 'mic-error',
+ code: 'permission-denied',
+ message: 'Permission dismissed',
+ });
+
+ expect(state?.permission).toBe('denied');
+ expect(state?.issue).toBe('permission-denied');
+ expect(state?.capturing).toBe(false);
+ });
+
+ it('keeps a missing device separate from a denied one', () => {
+ const tracker = createMicStateTracker();
+ const state = tracker.apply({
+ kind: 'mic-error',
+ code: 'no-device',
+ message: 'No input found',
+ });
+
+ expect(state?.issue).toBe('no-device');
+ // Nothing was denied - there is simply nothing plugged in.
+ expect(state?.permission).toBe('unknown');
+ });
+
+ it('collapses the two environment failures into the one with no user recovery', () => {
+ for (const code of ['unsupported', 'audio-init-failed'] as const) {
+ const tracker = createMicStateTracker();
+ const state = tracker.apply({ kind: 'mic-error', code, message: 'nope' });
+ expect(state?.issue).toBe('unavailable');
+ }
+ });
+
+ it('clears the fault when capture succeeds after a failure', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply({ kind: 'mic-error', code: 'permission-denied', message: 'denied' });
+ const state = tracker.apply(CAPTURE_START);
+
+ expect(state?.issue).toBeNull();
+ expect(state?.permission).toBe('granted');
+ });
+
+ it('publishes a device change even when our own capture is unaffected', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+
+ const state = tracker.apply({ kind: 'device-change' });
+ expect(state?.deviceChanged).toBe(true);
+ expect(state?.capturing).toBe(true);
+ expect(state?.deviceLabel).toBe('MacBook Pro Microphone');
+ });
+
+ it('never lets deviceChanged stick to the state', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+ tracker.apply({ kind: 'device-change' });
+
+ expect(tracker.state.deviceChanged).toBe(false);
+ // And the next real transition is not mislabelled as a device change.
+ expect(tracker.apply({ kind: 'capture-stop', reason: 'requested' })?.deviceChanged).toBe(false);
+ });
+
+ it('hands back a copy, so a client cannot mutate the tracker', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+
+ tracker.state.deviceLabel = 'something else';
+ expect(tracker.state.deviceLabel).toBe('MacBook Pro Microphone');
+ });
+
+ it('forgets everything on reset', () => {
+ const tracker = createMicStateTracker();
+ tracker.apply(CAPTURE_START);
+ tracker.reset();
+
+ expect(tracker.state).toEqual(INITIAL_MIC_STATE);
+ // A fresh tracker has to publish the first status it sees.
+ expect(tracker.apply(CAPTURE_START)).not.toBeNull();
+ });
+});
diff --git a/src/__tests__/main/acappella/audio/vad.test.ts b/src/__tests__/main/acappella/audio/vad.test.ts
new file mode 100644
index 0000000000..5985981792
--- /dev/null
+++ b/src/__tests__/main/acappella/audio/vad.test.ts
@@ -0,0 +1,478 @@
+/**
+ * A Cappella voice activity detection.
+ *
+ * The detector is pure and synchronous over frames, so every case here is
+ * generated PCM: sine tones for voiced speech, alternating samples for hiss, a
+ * sub-audio partial cycle for rumble, zeros for silence. No audio device, no
+ * fake timers, no `AudioContext` - if any of those were needed the VAD would
+ * have the wrong shape.
+ *
+ * Cases that assert exact thresholds run with `adaptiveNoiseFloor: false` so the
+ * configured absolute numbers are the operative ones. Adaptation gets its own
+ * block.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ ACAPPELLA_AUDIO_FRAME_SAMPLES,
+ ACAPPELLA_AUDIO_SAMPLE_RATE,
+} from '../../../../shared/acappella/audio-host';
+import type { AudioFrame } from '../../../../shared/acappella/audio-host';
+import {
+ DEFAULT_VAD_CONFIG,
+ VoiceActivityDetector,
+ createVoiceActivityDetector,
+ measure,
+ resolveVadConfig,
+} from '../../../../main/acappella/audio/vad';
+import type { VadConfig, VadEvent, VadFrameResult } from '../../../../main/acappella/audio/vad';
+
+// ---------------------------------------------------------------------------
+// Signal generators
+// ---------------------------------------------------------------------------
+
+const FRAME = ACAPPELLA_AUDIO_FRAME_SAMPLES;
+
+function build(fill: (index: number) => number): Int16Array {
+ const samples = new Int16Array(FRAME);
+ for (let i = 0; i < FRAME; i++) {
+ const value = Math.max(-1, Math.min(1, fill(i)));
+ samples[i] = value < 0 ? value * 0x8000 : value * 0x7fff;
+ }
+ return samples;
+}
+
+const silence = (): Int16Array => build(() => 0);
+
+/** Voiced speech stand-in: 200 Hz puts the zero-crossing rate squarely in band. */
+const tone = (amplitude: number, frequency = 200): Int16Array =>
+ build((i) => amplitude * Math.sin((2 * Math.PI * frequency * i) / ACAPPELLA_AUDIO_SAMPLE_RATE));
+
+/** Broadband transient / hiss: a sign flip every sample is the maximum possible ZCR. */
+const hiss = (amplitude: number): Int16Array =>
+ build((i) => (i % 2 === 0 ? amplitude : -amplitude));
+
+/** Rumble: 15 Hz never completes a cycle inside a 20 ms frame, so it never crosses zero. */
+const rumble = (amplitude: number): Int16Array => tone(amplitude, 15);
+
+function feed(detector: VoiceActivityDetector, frame: Int16Array, count: number): VadFrameResult[] {
+ const results: VadFrameResult[] = [];
+ for (let i = 0; i < count; i++) results.push(detector.process(frame));
+ return results;
+}
+
+function events(results: VadFrameResult[]): VadEvent[] {
+ return results.map((r) => r.event).filter((e): e is VadEvent => e !== null);
+}
+
+/** Deterministic thresholds: adaptation is exercised separately. */
+const fixed = (overrides: Partial = {}): VoiceActivityDetector =>
+ createVoiceActivityDetector({ adaptiveNoiseFloor: false, ...overrides });
+
+// ---------------------------------------------------------------------------
+
+describe('measure', () => {
+ it('reports the RMS of a full-scale tone as roughly 1/sqrt(2)', () => {
+ const { rms } = measure(tone(1));
+ expect(rms).toBeCloseTo(Math.SQRT1_2, 2);
+ });
+
+ it('reports zero for an empty frame rather than NaN', () => {
+ expect(measure(new Int16Array(0))).toEqual({ rms: 0, zeroCrossingRate: 0 });
+ });
+
+ it('reports a real level for a one-sample frame without dividing by zero', () => {
+ // A truncated frame is a transport artefact, not a signal. The level is
+ // still measurable; the crossing rate needs two samples to have a meaning,
+ // and a NaN here would poison every threshold comparison downstream.
+ const { rms, zeroCrossingRate } = measure(new Int16Array([16384]));
+ expect(rms).toBeCloseTo(0.5, 2);
+ expect(zeroCrossingRate).toBe(0);
+ });
+
+ it('puts a 200 Hz tone inside the default zero-crossing band', () => {
+ const { zeroCrossingRate } = measure(tone(0.5));
+ expect(zeroCrossingRate).toBeGreaterThan(DEFAULT_VAD_CONFIG.minZeroCrossingRate);
+ expect(zeroCrossingRate).toBeLessThan(DEFAULT_VAD_CONFIG.maxZeroCrossingRate);
+ });
+
+ it('puts hiss above the band and rumble below it', () => {
+ expect(measure(hiss(0.5)).zeroCrossingRate).toBeGreaterThan(
+ DEFAULT_VAD_CONFIG.maxZeroCrossingRate
+ );
+ expect(measure(rumble(0.5)).zeroCrossingRate).toBeLessThan(
+ DEFAULT_VAD_CONFIG.minZeroCrossingRate
+ );
+ });
+});
+
+describe('VoiceActivityDetector - onset', () => {
+ it('stays silent through silence', () => {
+ const vad = fixed();
+ const results = feed(vad, silence(), 100);
+ expect(events(results)).toEqual([]);
+ expect(vad.state).toBe('silence');
+ expect(results.every((r) => !r.active)).toBe(true);
+ });
+
+ it('opens on exactly the configured number of consecutive speech frames', () => {
+ const vad = fixed({ enterFrames: 4 });
+ const results = feed(vad, tone(0.2), 4);
+
+ expect(events(results.slice(0, 3))).toEqual([]);
+ expect(results[3].event).toEqual({ type: 'speech-start', atMs: 0 });
+ expect(vad.state).toBe('speech');
+ expect(results[3].active).toBe(true);
+ });
+
+ it('backdates speech-start to the first qualifying frame, not the deciding one', () => {
+ const vad = fixed({ enterFrames: 3, frameMs: 20 });
+ feed(vad, silence(), 5);
+ const results = feed(vad, tone(0.2), 3);
+
+ // Frames 0-4 were silence, so the onset begins at frame 5 => 100 ms.
+ expect(results[2].event).toEqual({ type: 'speech-start', atMs: 100 });
+ // The decision itself landed two frames later, at the 160 ms mark.
+ expect(results[2].elapsedMs).toBe(160);
+ });
+
+ it('does not open on a transient shorter than enterFrames', () => {
+ const vad = fixed({ enterFrames: 4 });
+ // Three loud frames, a gap, three more: the run counter resets, so neither
+ // burst is ever sustained enough to take the floor.
+ const results = [
+ ...feed(vad, tone(0.4), 3),
+ ...feed(vad, silence(), 2),
+ ...feed(vad, tone(0.4), 3),
+ ];
+
+ expect(events(results)).toEqual([]);
+ expect(vad.state).toBe('silence');
+ });
+
+ it('rejects loud hiss: energy alone is not speech', () => {
+ const vad = fixed();
+ expect(events(feed(vad, hiss(0.9), 50))).toEqual([]);
+ expect(vad.state).toBe('silence');
+ });
+
+ it('rejects loud rumble below the zero-crossing band', () => {
+ const vad = fixed();
+ expect(events(feed(vad, rumble(0.9), 50))).toEqual([]);
+ expect(vad.state).toBe('silence');
+ });
+
+ it('ignores the zero-crossing band once speech is open, so a trailing fricative sustains it', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 200 });
+ feed(vad, tone(0.3), 2);
+ expect(vad.state).toBe('speech');
+
+ // Hiss would never have opened the floor, but it is loud, so it holds it.
+ const results = feed(vad, hiss(0.3), 20);
+ expect(events(results)).toEqual([]);
+ expect(vad.state).toBe('speech');
+ });
+});
+
+describe('VoiceActivityDetector - hysteresis', () => {
+ it('sustains speech on levels that were too quiet to open it', () => {
+ const vad = fixed({ enterRms: 0.05, exitRms: 0.01, enterFrames: 2, endpointSilenceMs: 200 });
+ vad.processMeasurement(0.1, 0.05);
+ vad.processMeasurement(0.1, 0.05);
+ expect(vad.state).toBe('speech');
+
+ // Between the two thresholds: too quiet to have entered, loud enough to stay.
+ const held = Array.from({ length: 30 }, () => vad.processMeasurement(0.03, 0.05));
+ expect(events(held)).toEqual([]);
+ expect(vad.state).toBe('speech');
+ expect(held.every((r) => r.active)).toBe(true);
+ });
+
+ it('does not open on levels between the two thresholds', () => {
+ const vad = fixed({ enterRms: 0.05, exitRms: 0.01, enterFrames: 2 });
+ const results = Array.from({ length: 30 }, () => vad.processMeasurement(0.03, 0.05));
+ expect(events(results)).toEqual([]);
+ expect(vad.state).toBe('silence');
+ });
+});
+
+describe('VoiceActivityDetector - candidate frames', () => {
+ it('flags a voice-like frame long before it would open the floor', () => {
+ const vad = fixed({ enterFrames: 4 });
+ const results = feed(vad, tone(0.3), 3);
+
+ // Every frame looks like voice; none of them is enough evidence yet. This is
+ // the 80 ms head start the pipeline ducks TTS output on.
+ expect(results.map((r) => r.candidate)).toEqual([true, true, true]);
+ expect(events(results)).toEqual([]);
+ });
+
+ it('does not flag rumble or hiss, which is what makes the duck safe', () => {
+ const vad = fixed({ enterFrames: 4 });
+ expect(vad.process(rumble(0.3)).candidate).toBe(false);
+ expect(vad.process(hiss(0.3)).candidate).toBe(false);
+ expect(vad.process(silence()).candidate).toBe(false);
+ });
+
+ it('tracks energy alone once the floor is open', () => {
+ const vad = fixed({ enterRms: 0.05, exitRms: 0.01, enterFrames: 2 });
+ vad.processMeasurement(0.1, 0.05);
+ vad.processMeasurement(0.1, 0.05);
+
+ // A trailing fricative is high-ZCR and still carries the utterance, so the
+ // entry band does not apply on the way out.
+ expect(vad.process(hiss(0.3)).candidate).toBe(true);
+ expect(vad.process(silence()).candidate).toBe(false);
+ });
+});
+
+describe('VoiceActivityDetector - hangover', () => {
+ it('keeps frames active through the hangover and drops them after', () => {
+ const vad = fixed({ enterFrames: 2, hangoverFrames: 5, endpointSilenceMs: 700 });
+ feed(vad, tone(0.3), 2);
+
+ const quiet = feed(vad, silence(), 8);
+ // Silent frames 1..5 are hangover: still part of the utterance.
+ expect(quiet.slice(0, 5).map((r) => r.active)).toEqual([true, true, true, true, true]);
+ // From frame 6 the audio stops being fed onward, but the utterance is not
+ // over: the endpoint decision is a separate, longer clock.
+ expect(quiet.slice(5).map((r) => r.active)).toEqual([false, false, false]);
+ expect(quiet.map((r) => r.state)).toEqual(Array(8).fill('speech'));
+ });
+
+ it('resets the hangover when speech resumes', () => {
+ const vad = fixed({ enterFrames: 2, hangoverFrames: 3, endpointSilenceMs: 700 });
+ feed(vad, tone(0.3), 2);
+ feed(vad, silence(), 3);
+ const resumed = feed(vad, tone(0.3), 1);
+
+ expect(resumed[0].active).toBe(true);
+ expect(resumed[0].silenceMs).toBe(0);
+ });
+});
+
+describe('VoiceActivityDetector - endpointing', () => {
+ it('ends the utterance after the configured silence and reports its span', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 700, frameMs: 20 });
+ feed(vad, tone(0.3), 10); // 200 ms of speech
+ const quiet = feed(vad, silence(), 40);
+
+ const emitted = events(quiet);
+ expect(emitted).toEqual([
+ {
+ type: 'speech-end',
+ atMs: 200,
+ startedAtMs: 0,
+ durationMs: 200,
+ trailingSilenceMs: 700,
+ },
+ ]);
+ // 700 ms is 35 frames, so the decision lands on the 35th silent frame.
+ expect(quiet.findIndex((r) => r.event !== null)).toBe(34);
+ expect(vad.state).toBe('silence');
+ });
+
+ it('defaults the endpoint to 700 ms', () => {
+ expect(DEFAULT_VAD_CONFIG.endpointSilenceMs).toBe(700);
+ });
+
+ it('honours a shorter endpoint setting', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 300, frameMs: 20 });
+ feed(vad, tone(0.3), 5);
+ const quiet = feed(vad, silence(), 20);
+
+ expect(quiet.findIndex((r) => r.event !== null)).toBe(14); // 15 frames = 300 ms
+ expect(events(quiet)[0]).toMatchObject({ type: 'speech-end', trailingSilenceMs: 300 });
+ });
+
+ it('does not split an utterance on a pause shorter than the endpoint', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 700, frameMs: 20 });
+ feed(vad, tone(0.3), 5);
+ const pause = feed(vad, silence(), 20); // 400 ms: a person thinking
+ const second = feed(vad, tone(0.3), 5);
+
+ expect(events(pause)).toEqual([]);
+ expect(events(second)).toEqual([]);
+ expect(vad.state).toBe('speech');
+ });
+
+ it('dates speech-end to the last voiced frame, excluding the endpoint silence', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 200, frameMs: 20 });
+ feed(vad, tone(0.3), 5); // ends at 100 ms
+ feed(vad, silence(), 3); // a 60 ms gap, well short of the endpoint
+ feed(vad, tone(0.3), 2); // ends at 200 ms
+ const quiet = feed(vad, silence(), 10);
+
+ expect(events(quiet)[0]).toMatchObject({ atMs: 200, durationMs: 200 });
+ });
+
+ it('opens a second utterance after the first ends', () => {
+ const vad = fixed({ enterFrames: 2, endpointSilenceMs: 200, frameMs: 20 });
+ const all = [
+ ...feed(vad, tone(0.3), 5),
+ ...feed(vad, silence(), 12),
+ ...feed(vad, tone(0.3), 5),
+ ];
+
+ expect(events(all).map((e) => e.type)).toEqual(['speech-start', 'speech-end', 'speech-start']);
+ });
+});
+
+describe('VoiceActivityDetector - noise floor', () => {
+ it('rejects steady room noise that a fixed threshold would treat as speech', () => {
+ // ~0.028 RMS: above the 0.02 absolute enter threshold, and in the ZCR band.
+ const noise = tone(0.04);
+ expect(measure(noise).rms).toBeGreaterThan(DEFAULT_VAD_CONFIG.enterRms);
+
+ const withoutAdaptation = fixed();
+ expect(events(feed(withoutAdaptation, noise, 60)).map((e) => e.type)).toEqual(['speech-start']);
+
+ const withAdaptation = createVoiceActivityDetector();
+ expect(events(feed(withAdaptation, noise, 60))).toEqual([]);
+ expect(withAdaptation.state).toBe('silence');
+ });
+
+ it('still opens on speech that clears the raised threshold', () => {
+ const vad = createVoiceActivityDetector();
+ feed(vad, tone(0.04), 60); // let the floor settle on the room
+ expect(vad.noiseFloor).toBeGreaterThan(0.01);
+
+ expect(events(feed(vad, tone(0.5), 10)).map((e) => e.type)).toEqual(['speech-start']);
+ });
+
+ it('clamps the floor so noise can never adapt the microphone into deafness', () => {
+ const vad = createVoiceActivityDetector();
+ // Hiss is loud but out of band, so it trains the floor without ever opening.
+ feed(vad, hiss(0.9), 500);
+ expect(vad.noiseFloor).toBe(DEFAULT_VAD_CONFIG.maxNoiseFloor);
+ expect(vad.state).toBe('silence');
+ });
+
+ it('follows a room back down quickly when the noise stops', () => {
+ const vad = createVoiceActivityDetector();
+ feed(vad, hiss(0.9), 500);
+ feed(vad, silence(), 20);
+ expect(vad.noiseFloor).toBeLessThan(0.001);
+ });
+
+ it('freezes the estimate while an utterance is open', () => {
+ const vad = createVoiceActivityDetector({ enterFrames: 2, endpointSilenceMs: 10_000 });
+ feed(vad, tone(0.5), 2);
+ const floorAtOnset = vad.noiseFloor;
+
+ feed(vad, tone(0.5), 200);
+ expect(vad.noiseFloor).toBe(floorAtOnset);
+ });
+
+ it('does not delay onset for someone who speaks the instant the mic opens', () => {
+ // Calibration trains on speech here, which is exactly the case the ceiling
+ // exists for: the floor saturates and real speech still clears it.
+ const vad = createVoiceActivityDetector({ enterFrames: 4 });
+ const results = feed(vad, tone(0.5), 4);
+ expect(results[3].event).toEqual({ type: 'speech-start', atMs: 0 });
+ });
+
+ it('needs no calibration pass when it is disabled', () => {
+ // Without the fast head start the floor cannot outrun a noisy room, and the
+ // detector latches open on noise a calibrated one rejects.
+ const vad = createVoiceActivityDetector({ calibrationFrames: 0 });
+ expect(events(feed(vad, tone(0.04), 60)).map((e) => e.type)).toEqual(['speech-start']);
+ });
+
+ it('reports a zero floor when adaptation is disabled', () => {
+ const vad = fixed();
+ const results = feed(vad, tone(0.5), 5);
+ expect(results.every((r) => r.noiseFloor === 0)).toBe(true);
+ });
+});
+
+describe('VoiceActivityDetector - lifecycle', () => {
+ it('reset clears the open utterance and the frame clock', () => {
+ const vad = fixed({ enterFrames: 2 });
+ feed(vad, tone(0.3), 5);
+ expect(vad.state).toBe('speech');
+
+ vad.reset();
+ expect(vad.state).toBe('silence');
+ expect(vad.elapsedMs).toBe(0);
+ expect(vad.noiseFloor).toBe(0);
+
+ // No stale speech-end for audio from the previous run.
+ expect(events(feed(vad, silence(), 60))).toEqual([]);
+ });
+
+ it('accepts an AudioFrame straight off the wire', () => {
+ const vad = fixed({ enterFrames: 2 });
+ const pcm = tone(0.3);
+ const buffer = new ArrayBuffer(pcm.byteLength);
+ new Int16Array(buffer).set(pcm);
+ const frame: AudioFrame = {
+ seq: 1,
+ capturedAt: 1_700_000_000_000,
+ // Deliberately wrong: the detector measures the samples, not this field.
+ rms: 0,
+ pcm: buffer,
+ };
+
+ expect(vad.processFrame(frame).rms).toBeCloseTo(measure(pcm).rms, 5);
+ expect(vad.processFrame(frame).event).toEqual({ type: 'speech-start', atMs: 0 });
+ });
+
+ it('advances its clock in frames, independent of wall time', () => {
+ const vad = fixed({ frameMs: 20 });
+ feed(vad, silence(), 50);
+ expect(vad.elapsedMs).toBe(1000);
+ });
+});
+
+describe('resolveVadConfig', () => {
+ it('fills in the defaults', () => {
+ expect(resolveVadConfig()).toEqual(DEFAULT_VAD_CONFIG);
+ });
+
+ it('pins the exit threshold below the enter threshold', () => {
+ // An exit at or above the enter threshold would open and close on one frame.
+ const config = resolveVadConfig({ enterRms: 0.03, exitRms: 0.5 });
+ expect(config.exitRms).toBe(0.03);
+ });
+
+ it('clamps rather than throws on nonsense, because these come from user settings', () => {
+ const config = resolveVadConfig({
+ frameMs: -5,
+ enterFrames: 0,
+ hangoverFrames: -3,
+ endpointSilenceMs: 1,
+ enterRms: 5,
+ maxZeroCrossingRate: 9,
+ noiseFloorEnterMargin: 0.1,
+ });
+
+ expect(config.frameMs).toBe(1);
+ expect(config.enterFrames).toBe(1);
+ expect(config.hangoverFrames).toBe(0);
+ expect(config.endpointSilenceMs).toBeGreaterThanOrEqual(config.frameMs);
+ expect(config.enterRms).toBe(1);
+ expect(config.maxZeroCrossingRate).toBe(1);
+ expect(config.noiseFloorEnterMargin).toBe(1);
+ });
+
+ it('falls back to the default for a non-finite value', () => {
+ const config = resolveVadConfig({ endpointSilenceMs: Number.NaN, enterRms: Number.NaN });
+ expect(config.endpointSilenceMs).toBe(DEFAULT_VAD_CONFIG.endpointSilenceMs);
+ expect(config.enterRms).toBe(DEFAULT_VAD_CONFIG.enterRms);
+ });
+
+ it('never lets the endpoint fall below a single frame', () => {
+ const config = resolveVadConfig({ frameMs: 20, endpointSilenceMs: 0 });
+ expect(config.endpointSilenceMs).toBe(20);
+ });
+});
+
+describe('VoiceActivityDetector construction', () => {
+ it('exposes the resolved config', () => {
+ const vad = new VoiceActivityDetector({ endpointSilenceMs: 450 });
+ expect(vad.config.endpointSilenceMs).toBe(450);
+ expect(vad.config.enterRms).toBe(DEFAULT_VAD_CONFIG.enterRms);
+ });
+});
diff --git a/src/__tests__/main/acappella/echo-stt.test.ts b/src/__tests__/main/acappella/echo-stt.test.ts
new file mode 100644
index 0000000000..0685dc7e56
--- /dev/null
+++ b/src/__tests__/main/acappella/echo-stt.test.ts
@@ -0,0 +1,311 @@
+/**
+ * @file echo-stt.test.ts
+ *
+ * The development echo recogniser: PCM in, speech segments out.
+ *
+ * Everything here is generated PCM against recording callbacks. No audio device,
+ * no pipeline, no audio host - the provider owns its own segmentation precisely
+ * so it can be driven from an `Int16Array` and nothing else.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+import { EchoSttProvider, createEchoSttProvider } from '../../../main/acappella/providers/echo-stt';
+import {
+ ACAPPELLA_AUDIO_FRAME_SAMPLES,
+ ACAPPELLA_AUDIO_SAMPLE_RATE,
+} from '../../../shared/acappella/audio-host';
+import type { SttCallbacks } from '../../../shared/acappella/providers';
+
+// ---------------------------------------------------------------------------
+// Signal generators
+// ---------------------------------------------------------------------------
+
+function build(fill: (index: number) => number): Int16Array {
+ const samples = new Int16Array(ACAPPELLA_AUDIO_FRAME_SAMPLES);
+ for (let i = 0; i < ACAPPELLA_AUDIO_FRAME_SAMPLES; i++) {
+ const value = Math.max(-1, Math.min(1, fill(i)));
+ samples[i] = value < 0 ? value * 0x8000 : value * 0x7fff;
+ }
+ return samples;
+}
+
+const silence = (): Int16Array => build(() => 0);
+
+/** Voiced speech stand-in: 200 Hz sits squarely inside the VAD's zero-crossing band. */
+const tone = (amplitude = 0.4): Int16Array =>
+ build((i) => amplitude * Math.sin((2 * Math.PI * 200 * i) / ACAPPELLA_AUDIO_SAMPLE_RATE));
+
+/** Silence long enough for the default 700 ms endpoint to fire. */
+const ENDPOINT_FRAMES = 40;
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+interface Recorded {
+ partials: Array<{ text: string; stability: number }>;
+ finals: Array<{ text: string; confidence: number; durationMs?: number }>;
+ errors: Error[];
+}
+
+function recording(): { callbacks: SttCallbacks; recorded: Recorded } {
+ const recorded: Recorded = { partials: [], finals: [], errors: [] };
+ return {
+ recorded,
+ callbacks: {
+ onPartial: (text, stability) => recorded.partials.push({ text, stability }),
+ onFinal: (text, confidence, durationMs) =>
+ recorded.finals.push({ text, confidence, durationMs }),
+ onError: (error) => recorded.errors.push(error),
+ },
+ };
+}
+
+async function started(options: ConstructorParameters[0] = {}) {
+ const provider = new EchoSttProvider({ finalDelayMs: 0, ...options });
+ const { callbacks, recorded } = recording();
+ await provider.start(callbacks);
+ return { provider, recorded };
+}
+
+function push(provider: EchoSttProvider, samples: Int16Array, count = 1): void {
+ for (let i = 0; i < count; i++) provider.feed(samples);
+}
+
+// ---------------------------------------------------------------------------
+
+describe('EchoSttProvider identity', () => {
+ it('declares itself an audio consumer at the pipeline sample rate', () => {
+ const provider = createEchoSttProvider();
+
+ // The flag the audio bridge reads before opening a microphone.
+ expect(provider.acceptsAudio).toBe(true);
+ expect(provider.sampleRate).toBe(ACAPPELLA_AUDIO_SAMPLE_RATE);
+ expect(provider.tier).toBe('mock');
+ });
+});
+
+describe('EchoSttProvider segmentation', () => {
+ it('says nothing about a silent room', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, silence(), 100);
+
+ expect(recorded.finals).toEqual([]);
+ expect(recorded.partials).toEqual([]);
+ });
+
+ it('emits a final transcript once a speech segment endpoints', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ expect(recorded.finals).toHaveLength(1);
+ expect(recorded.finals[0].text).toMatch(/^Echo utterance 1: 0\.2s of speech\.$/);
+ expect(recorded.finals[0].confidence).toBeGreaterThan(0);
+ expect(recorded.finals[0].confidence).toBeLessThan(1);
+ });
+
+ it('reports the speech duration without the endpoint silence in it', async () => {
+ const { provider, recorded } = await started();
+
+ // 25 frames of tone is 500 ms. The 800 ms of silence that ends the utterance
+ // is the detector agreeing the user stopped, not something the user said.
+ push(provider, tone(), 25);
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ expect(recorded.finals[0].durationMs).toBe(500);
+ });
+
+ it('numbers consecutive segments so one transcript is told from the next', async () => {
+ const { provider, recorded } = await started();
+
+ for (let segment = 0; segment < 3; segment++) {
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+ }
+
+ expect(recorded.finals.map((final) => final.text)).toEqual([
+ 'Echo utterance 1: 0.2s of speech.',
+ 'Echo utterance 2: 0.2s of speech.',
+ 'Echo utterance 3: 0.2s of speech.',
+ ]);
+ });
+
+ it('streams partials while the segment is open, with rising stability', async () => {
+ const { provider, recorded } = await started({ partialIntervalMs: 200 });
+
+ push(provider, tone(), 60);
+
+ expect(recorded.partials.length).toBeGreaterThan(2);
+ expect(recorded.partials[0].text).toMatch(/^Echo utterance 1: .*\.\.\.$/);
+ // A hypothesis firms up as more of the utterance arrives.
+ expect(recorded.partials[1].stability).toBeGreaterThan(recorded.partials[0].stability);
+ expect(recorded.partials.at(-1)!.stability).toBeLessThanOrEqual(0.9);
+ // Still open: nothing is final until the floor closes.
+ expect(recorded.finals).toEqual([]);
+ });
+
+ it('emits no partials at all when the interval is disabled', async () => {
+ const { provider, recorded } = await started({ partialIntervalMs: 0 });
+
+ push(provider, tone(), 60);
+
+ expect(recorded.partials).toEqual([]);
+ });
+
+ it('ignores frames fed before start', async () => {
+ const provider = new EchoSttProvider({ finalDelayMs: 0 });
+ const { callbacks, recorded } = recording();
+
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+ await provider.start(callbacks);
+
+ expect(recorded.finals).toEqual([]);
+ });
+
+ it('starts a fresh run on restart rather than continuing the last one', async () => {
+ const { provider, recorded } = await started();
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ await provider.stop();
+ const second = recording();
+ await provider.start(second.callbacks);
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ // Segment 1 again, and on the new callbacks: a new capture run is a new
+ // conversation, not the continuation of a session that already ended.
+ expect(second.recorded.finals.map((f) => f.text)).toEqual([
+ 'Echo utterance 1: 0.2s of speech.',
+ ]);
+ expect(recorded.finals).toHaveLength(1);
+ });
+});
+
+describe('EchoSttProvider endpointing', () => {
+ it('finalises the open segment immediately on flush', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, tone(), 10);
+ expect(recorded.finals).toEqual([]);
+
+ // Push-to-talk release, or the pipeline forwarding its own VAD endpoint. The
+ // user already said they were finished; waiting out 700 ms of silence to
+ // agree with them would be latency bought with nothing.
+ await provider.flush();
+
+ expect(recorded.finals).toHaveLength(1);
+ expect(recorded.finals[0].durationMs).toBe(200);
+ });
+
+ it('produces nothing when flushed with no speech in hand', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, silence(), 10);
+ await provider.flush();
+
+ // Silence is not an empty transcript, it is no transcript.
+ expect(recorded.finals).toEqual([]);
+ });
+
+ it('hears the next sentence right after a flush', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, tone(), 10);
+ await provider.flush();
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ // A manual endpoint ends an utterance, not the capture run: someone who
+ // keeps talking must not have to pause before being heard again.
+ expect(recorded.finals.map((f) => f.text)).toEqual([
+ 'Echo utterance 1: 0.2s of speech.',
+ 'Echo utterance 2: 0.2s of speech.',
+ ]);
+ });
+
+ it('does not double-report a segment that flush already closed', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, tone(), 10);
+ await provider.flush();
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ expect(recorded.finals).toHaveLength(1);
+ });
+});
+
+describe('EchoSttProvider text-in seam', () => {
+ it('routes typed text in as a synthetic final transcript', async () => {
+ const { provider, recorded } = await started();
+
+ provider.injectUtterance(' open the auth tab ');
+
+ // No partials: the text was already settled when it arrived, so there is no
+ // hypothesis to revise.
+ expect(recorded.partials).toEqual([]);
+ expect(recorded.finals).toEqual([
+ { text: 'open the auth tab', confidence: 1, durationMs: expect.any(Number) },
+ ]);
+ expect(recorded.finals[0].durationMs).toBeGreaterThan(0);
+ });
+
+ it('passes an empty utterance straight through with no estimated duration', async () => {
+ const { provider, recorded } = await started();
+
+ provider.injectUtterance(' ');
+
+ // The session service has its own empty-utterance path; inventing a duration
+ // for it would put a lie on the transcript timeline.
+ expect(recorded.finals).toEqual([{ text: '', confidence: 1, durationMs: 0 }]);
+ });
+
+ it('supersedes audio that was being spoken over it', async () => {
+ const { provider, recorded } = await started();
+
+ push(provider, tone(), 10);
+ provider.injectUtterance('typed instead');
+ push(provider, silence(), ENDPOINT_FRAMES);
+
+ // The open segment is abandoned, not endpointed behind the typed text.
+ expect(recorded.finals.map((f) => f.text)).toEqual(['typed instead']);
+ });
+});
+
+describe('EchoSttProvider decoder latency', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('delivers the final after the simulated decode, not on the endpoint frame', async () => {
+ const provider = new EchoSttProvider({ finalDelayMs: 250 });
+ const { callbacks, recorded } = recording();
+ await provider.start(callbacks);
+
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+ expect(recorded.finals).toEqual([]);
+
+ vi.advanceTimersByTime(250);
+ expect(recorded.finals).toHaveLength(1);
+ });
+
+ it('drops a final that was still decoding when the session ended', async () => {
+ const provider = new EchoSttProvider({ finalDelayMs: 250 });
+ const { callbacks, recorded } = recording();
+ await provider.start(callbacks);
+
+ push(provider, tone(), 10);
+ push(provider, silence(), ENDPOINT_FRAMES);
+ await provider.stop();
+ vi.advanceTimersByTime(1_000);
+
+ // A transcript for a session that is gone would arrive with no floor to take
+ // it and no envelope to travel in.
+ expect(recorded.finals).toEqual([]);
+ });
+});
diff --git a/src/__tests__/main/acappella/hotkeys/press-hold.test.ts b/src/__tests__/main/acappella/hotkeys/press-hold.test.ts
new file mode 100644
index 0000000000..4bb3ac0fcf
--- /dev/null
+++ b/src/__tests__/main/acappella/hotkeys/press-hold.test.ts
@@ -0,0 +1,303 @@
+/**
+ * @file press-hold.test.ts
+ *
+ * Tap versus hold on a global hotkey, including the boundary itself and the
+ * tap-only fallback that every platform gets today.
+ *
+ * Time and key state are both injected, so nothing here depends on a real
+ * keyboard or on how fast the machine running the suite happens to be. The
+ * boundary cases are the point: a threshold that classified a 299 ms press and a
+ * 301 ms press the same way would make push-to-talk feel random.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import {
+ DEFAULT_HOLD_THRESHOLD_MS,
+ MAX_HOLD_MS,
+ MAX_HOLD_THRESHOLD_MS,
+ MIN_HOLD_THRESHOLD_MS,
+ PressHoldDetector,
+ createPressHoldDetector,
+ describePressHoldCapability,
+ resolveHoldThresholdMs,
+ resolvePlatformKeyStateProbe,
+ resolvePressHoldCapability,
+ setKeyStateProbe,
+ type KeyStateProbe,
+} from '../../../../main/acappella/hotkeys/press-hold';
+
+const ACCELERATOR = 'Command+Alt+V';
+const POLL_MS = 10;
+
+interface Harness {
+ detector: PressHoldDetector;
+ taps: number[];
+ holdStarts: number[];
+ holdEnds: number[];
+ /** Let the key back up. */
+ release: () => void;
+ /** Advance the fake clock and run the polls that fall inside the step. */
+ advance: (ms: number) => void;
+}
+
+function makeHarness(options: { threshold?: number; probe?: KeyStateProbe | null } = {}): Harness {
+ let now = 0;
+ let down = true;
+ const taps: number[] = [];
+ const holdStarts: number[] = [];
+ const holdEnds: number[] = [];
+
+ const probe =
+ options.probe === undefined ? (((): boolean => down) as KeyStateProbe) : options.probe;
+
+ const detector = createPressHoldDetector({
+ accelerator: ACCELERATOR,
+ holdThresholdMs: options.threshold,
+ pollIntervalMs: POLL_MS,
+ probe,
+ now: () => now,
+ onTap: () => taps.push(now),
+ onHoldStart: () => holdStarts.push(now),
+ onHoldEnd: () => holdEnds.push(now),
+ });
+
+ return {
+ detector,
+ taps,
+ holdStarts,
+ holdEnds,
+ release: () => {
+ down = false;
+ },
+ // Stepped at the poll interval rather than jumped, so the detector sees the
+ // same sequence of elapsed times it would see on a real clock. Jumping
+ // would hand the first poll the whole span and skip the threshold entirely.
+ advance: (ms) => {
+ let remaining = ms;
+ while (remaining > 0) {
+ const step = Math.min(POLL_MS, remaining);
+ now += step;
+ vi.advanceTimersByTime(step);
+ remaining -= step;
+ }
+ },
+ };
+}
+
+describe('resolveHoldThresholdMs', () => {
+ it('clamps into the usable band and falls back on nonsense', () => {
+ expect(resolveHoldThresholdMs(400)).toBe(400);
+ expect(resolveHoldThresholdMs(1)).toBe(MIN_HOLD_THRESHOLD_MS);
+ expect(resolveHoldThresholdMs(999_999)).toBe(MAX_HOLD_THRESHOLD_MS);
+ expect(resolveHoldThresholdMs('later')).toBe(DEFAULT_HOLD_THRESHOLD_MS);
+ expect(resolveHoldThresholdMs(Number.NaN)).toBe(DEFAULT_HOLD_THRESHOLD_MS);
+ });
+});
+
+describe('capability reporting', () => {
+ afterEach(() => setKeyStateProbe(null));
+
+ it('is tap-only with no probe and hold-and-tap with one', () => {
+ expect(resolvePressHoldCapability(null)).toBe('tap-only');
+ expect(resolvePressHoldCapability(() => true)).toBe('hold-and-tap');
+ });
+
+ it('never silently degrades: the tap-only sentence says holding will not work', () => {
+ const note = describePressHoldCapability('tap-only');
+ expect(note.toLowerCase()).toContain('hold');
+ expect(note).not.toBe(describePressHoldCapability('hold-and-tap'));
+ });
+
+ it('picks up a probe installed at runtime', () => {
+ expect(resolvePlatformKeyStateProbe()).toBeNull();
+ const probe: KeyStateProbe = () => true;
+ setKeyStateProbe(probe);
+ expect(resolvePlatformKeyStateProbe()).toBe(probe);
+ });
+});
+
+describe('PressHoldDetector', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ describe('tap-only fallback', () => {
+ it('taps immediately on every trigger, with no timer at all', () => {
+ const harness = makeHarness({ probe: null });
+ expect(harness.detector.capability).toBe('tap-only');
+
+ harness.detector.trigger();
+ harness.detector.trigger();
+
+ expect(harness.taps).toHaveLength(2);
+ expect(harness.holdStarts).toHaveLength(0);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+ });
+
+ describe('threshold boundary', () => {
+ it('classifies a press released just under the threshold as a tap', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(290);
+ harness.release();
+ harness.advance(POLL_MS);
+
+ expect(harness.taps).toHaveLength(1);
+ expect(harness.holdStarts).toHaveLength(0);
+ expect(harness.holdEnds).toHaveLength(0);
+ });
+
+ it('opens the floor once the key is still down at the threshold', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(300);
+
+ expect(harness.holdStarts).toEqual([300]);
+ expect(harness.taps).toHaveLength(0);
+ expect(harness.detector.isHolding).toBe(true);
+ });
+
+ it('ends the utterance on release after a hold, and never taps', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(500);
+ harness.release();
+ harness.advance(POLL_MS);
+
+ expect(harness.holdStarts).toHaveLength(1);
+ expect(harness.holdEnds).toHaveLength(1);
+ expect(harness.taps).toHaveLength(0);
+ expect(harness.detector.isHolding).toBe(false);
+ });
+
+ it('fires hold-start exactly once across a long hold', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(2000);
+ expect(harness.holdStarts).toHaveLength(1);
+ });
+ });
+
+ it('ignores auto-repeat while a press is being classified', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(50);
+ harness.detector.trigger();
+ harness.detector.trigger();
+ harness.advance(300);
+ harness.release();
+ harness.advance(POLL_MS);
+
+ expect(harness.holdStarts).toHaveLength(1);
+ expect(harness.holdEnds).toHaveLength(1);
+ });
+
+ it('starts a fresh gesture after the previous one resolved', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(50);
+ harness.release();
+ harness.advance(POLL_MS);
+ expect(harness.taps).toHaveLength(1);
+
+ harness.detector.trigger();
+ harness.advance(POLL_MS);
+ expect(harness.taps).toHaveLength(2);
+ });
+
+ it('stops polling once a gesture resolves', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.release();
+ harness.advance(POLL_MS);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('releases the floor when a probe lies about the key staying down', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(MAX_HOLD_MS);
+
+ expect(harness.holdStarts).toHaveLength(1);
+ expect(harness.holdEnds).toHaveLength(1);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('treats a probe that throws as a release rather than holding forever', () => {
+ let now = 0;
+ const taps: number[] = [];
+ const detector = createPressHoldDetector({
+ accelerator: ACCELERATOR,
+ pollIntervalMs: POLL_MS,
+ probe: () => {
+ throw new Error('probe exploded');
+ },
+ now: () => now,
+ onTap: () => taps.push(now),
+ onHoldStart: vi.fn(),
+ onHoldEnd: vi.fn(),
+ });
+
+ detector.trigger();
+ now += POLL_MS;
+ vi.advanceTimersByTime(POLL_MS);
+
+ expect(taps).toHaveLength(1);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('dispose ends a hold in flight rather than abandoning an open floor', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(400);
+ expect(harness.holdStarts).toHaveLength(1);
+
+ harness.detector.dispose();
+ expect(harness.holdEnds).toHaveLength(1);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('dispose before the threshold neither taps nor holds', () => {
+ const harness = makeHarness({ threshold: 300 });
+ harness.detector.trigger();
+ harness.advance(100);
+ harness.detector.dispose();
+
+ expect(harness.taps).toHaveLength(0);
+ expect(harness.holdEnds).toHaveLength(0);
+ });
+
+ it('ignores triggers after dispose', () => {
+ const harness = makeHarness({ probe: null });
+ harness.detector.dispose();
+ harness.detector.trigger();
+ expect(harness.taps).toHaveLength(0);
+ });
+
+ it('does not let a throwing callback leave the poll timer running', () => {
+ let now = 0;
+ let down = true;
+ const detector = new PressHoldDetector({
+ accelerator: ACCELERATOR,
+ pollIntervalMs: POLL_MS,
+ probe: () => down,
+ now: () => now,
+ onTap: () => {
+ throw new Error('floor exploded');
+ },
+ onHoldStart: vi.fn(),
+ onHoldEnd: vi.fn(),
+ });
+
+ detector.trigger();
+ down = false;
+ now += POLL_MS;
+ expect(() => vi.advanceTimersByTime(POLL_MS)).not.toThrow();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/src/__tests__/main/acappella/hotkeys/voice-hotkeys.test.ts b/src/__tests__/main/acappella/hotkeys/voice-hotkeys.test.ts
new file mode 100644
index 0000000000..4f852cccb0
--- /dev/null
+++ b/src/__tests__/main/acappella/hotkeys/voice-hotkeys.test.ts
@@ -0,0 +1,269 @@
+/**
+ * @file voice-hotkeys.test.ts
+ *
+ * The two A Cappella hotkeys: what each one does to window focus, how a scope is
+ * resolved, and every way a press is refused rather than silently doing nothing.
+ *
+ * The focus rule is the interesting one and it is asymmetric on purpose. Talking
+ * to the Conductor must NOT steal focus, because the whole point is speaking to
+ * Maestro from inside another application; talking to the current agent must,
+ * because "current" is a thing you have to be looking at to mean.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('electron', () => ({
+ app: { show: vi.fn() },
+ BrowserWindow: class {},
+ globalShortcut: { register: vi.fn(() => true), unregister: vi.fn() },
+}));
+
+vi.mock('../../../../shared/platformDetection', () => ({
+ isMacOS: () => true,
+ isWindows: () => false,
+ isLinux: () => false,
+}));
+
+import type { VoiceScope, WakeSource } from '../../../../shared/acappella/protocol';
+import {
+ VOICE_AGENT_HOTKEY_ID,
+ VOICE_CONDUCTOR_HOTKEY_ID,
+} from '../../../../shared/global-hotkeys';
+import {
+ GlobalHotkeyRegistry,
+ type GlobalShortcutBackend,
+} from '../../../../main/global-hotkey-manager';
+import type { FloorControlConfig } from '../../../../main/acappella/audio/floor-control';
+import {
+ VoiceHotkeyController,
+ createVoiceHotkeyController,
+ defaultVoiceHotkeyKeys,
+ type VoiceFloorSurface,
+ type VoiceHotkeyRefusalInfo,
+} from '../../../../main/acappella/hotkeys/voice-hotkeys';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+class FakeBackend implements GlobalShortcutBackend {
+ readonly bound = new Map void>();
+ register(accelerator: string, callback: () => void): boolean {
+ this.bound.set(accelerator, callback);
+ return true;
+ }
+ unregister(accelerator: string): void {
+ this.bound.delete(accelerator);
+ }
+ fire(accelerator: string): void {
+ this.bound.get(accelerator)?.();
+ }
+}
+
+class FakeFloor implements VoiceFloorSurface {
+ mode: FloorControlConfig['mode'] = 'tap-to-toggle';
+ readonly presses: WakeSource[] = [];
+ readonly releases: WakeSource[] = [];
+ readonly modes: string[] = [];
+
+ configure(overrides: Partial): void {
+ if (overrides.mode) {
+ this.mode = overrides.mode;
+ this.modes.push(overrides.mode);
+ }
+ }
+ async press(source: WakeSource = 'client-button'): Promise {
+ this.presses.push(source);
+ }
+ async release(source: WakeSource = 'client-button'): Promise {
+ this.releases.push(source);
+ }
+}
+
+describe('VoiceHotkeyController', () => {
+ let backend: FakeBackend;
+ let registry: GlobalHotkeyRegistry;
+ let floor: FakeFloor;
+ let scopes: VoiceScope[];
+ let summons: number;
+ let refusals: VoiceHotkeyRefusalInfo[];
+ let available: { ok: true } | { ok: false; reason: 'feature-disabled'; message: string };
+ let focusedAgent: string | null;
+
+ function build(overrides: Record = {}): VoiceHotkeyController {
+ return createVoiceHotkeyController({
+ registry,
+ checkAvailability: () => available,
+ acquireFloor: (scope) => {
+ scopes.push(scope);
+ return floor;
+ },
+ resolveFocusedAgent: () => (focusedAgent ? { kind: 'agent', sessionId: focusedAgent } : null),
+ summon: () => {
+ summons += 1;
+ },
+ onRefused: (info) => refusals.push(info),
+ // Forces tap-only, which is what every platform gets today.
+ probe: null,
+ ...overrides,
+ });
+ }
+
+ beforeEach(() => {
+ backend = new FakeBackend();
+ registry = new GlobalHotkeyRegistry(backend);
+ floor = new FakeFloor();
+ scopes = [];
+ summons = 0;
+ refusals = [];
+ available = { ok: true };
+ focusedAgent = 'agent-7';
+ });
+
+ it('binds both hotkeys to their shipped defaults', () => {
+ const controller = build();
+ const statuses = controller.sync();
+
+ expect(statuses[VOICE_CONDUCTOR_HOTKEY_ID].registered).toBe(true);
+ expect(statuses[VOICE_AGENT_HOTKEY_ID].registered).toBe(true);
+ expect(statuses[VOICE_CONDUCTOR_HOTKEY_ID].keys).toEqual(
+ defaultVoiceHotkeyKeys()[VOICE_CONDUCTOR_HOTKEY_ID]
+ );
+ });
+
+ it('ships defaults that do not collide with each other', () => {
+ const controller = build();
+ const statuses = controller.sync();
+ expect(statuses[VOICE_AGENT_HOTKEY_ID].reason).toBeUndefined();
+ expect(statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator).not.toBe(
+ statuses[VOICE_AGENT_HOTKEY_ID].accelerator
+ );
+ });
+
+ it('honours an explicitly cleared binding rather than restoring the default', () => {
+ const controller = build();
+ const statuses = controller.sync({ [VOICE_CONDUCTOR_HOTKEY_ID]: [] });
+ expect(statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator).toBeNull();
+ expect(statuses[VOICE_AGENT_HOTKEY_ID].registered).toBe(true);
+ });
+
+ it('opens a Conductor session without touching window focus', () => {
+ const controller = build();
+ const statuses = controller.sync();
+ backend.fire(statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator!);
+
+ expect(summons).toBe(0);
+ expect(scopes).toEqual([{ kind: 'conductor' }]);
+ expect(floor.presses).toEqual(['hotkey']);
+ expect(floor.mode).toBe('tap-to-toggle');
+ });
+
+ it('summons Maestro and binds the focused agent', () => {
+ const controller = build();
+ const statuses = controller.sync();
+ backend.fire(statuses[VOICE_AGENT_HOTKEY_ID].accelerator!);
+
+ expect(summons).toBe(1);
+ expect(scopes).toEqual([{ kind: 'agent', sessionId: 'agent-7' }]);
+ });
+
+ it('refuses the agent hotkey rather than guessing when nothing is focused', () => {
+ focusedAgent = null;
+ const controller = build();
+ const statuses = controller.sync();
+ backend.fire(statuses[VOICE_AGENT_HOTKEY_ID].accelerator!);
+
+ expect(refusals.map((r) => r.reason)).toEqual(['no-focused-agent']);
+ // Notably NOT a fall back to the Conductor: a spoken instruction must not
+ // land somewhere the user did not aim it.
+ expect(scopes).toHaveLength(0);
+ expect(floor.presses).toHaveLength(0);
+ });
+
+ it('refuses with a reason when the Encore Feature is off', () => {
+ available = { ok: false, reason: 'feature-disabled', message: 'A Cappella is switched off.' };
+ const controller = build();
+ const statuses = controller.sync();
+ backend.fire(statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator!);
+
+ expect(refusals[0]).toMatchObject({
+ id: VOICE_CONDUCTOR_HOTKEY_ID,
+ reason: 'feature-disabled',
+ });
+ expect(floor.presses).toHaveLength(0);
+ });
+
+ it('refuses when nothing can give it a floor', () => {
+ const controller = build({ acquireFloor: () => null });
+ const statuses = controller.sync();
+ backend.fire(statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator!);
+
+ expect(refusals.map((r) => r.reason)).toEqual(['no-floor']);
+ });
+
+ it('reports tap-only on a platform with no key-release signal', () => {
+ expect(build().capability).toBe('tap-only');
+ });
+
+ it('holds the floor open while the key is down when a probe exists', () => {
+ vi.useFakeTimers();
+ try {
+ let now = 0;
+ let down = true;
+ const controller = new VoiceHotkeyController({
+ registry,
+ checkAvailability: () => available,
+ acquireFloor: () => floor,
+ resolveFocusedAgent: () => null,
+ summon: vi.fn(),
+ probe: () => down,
+ getHoldThresholdMs: () => 300,
+ });
+ const statuses = controller.sync();
+ const accelerator = statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator!;
+
+ vi.setSystemTime(now);
+ backend.fire(accelerator);
+ // Past the threshold with the key still down: the floor opens in hold mode.
+ now += 400;
+ vi.setSystemTime(now);
+ vi.advanceTimersByTime(400);
+ expect(floor.mode).toBe('hold-to-talk');
+ expect(floor.presses).toEqual(['hotkey']);
+
+ down = false;
+ now += 50;
+ vi.setSystemTime(now);
+ vi.advanceTimersByTime(50);
+ expect(floor.releases).toEqual(['hotkey']);
+ // The user's own mode is restored: a push-to-talk gesture must not convert
+ // the session to push-to-talk forever.
+ expect(floor.mode).toBe('tap-to-toggle');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('dispose releases both combos', () => {
+ const controller = build();
+ controller.sync();
+ controller.dispose();
+
+ expect(backend.bound.size).toBe(0);
+ expect(registry.status(VOICE_CONDUCTOR_HOTKEY_ID)).toBeNull();
+ });
+
+ it('ignores presses after dispose', () => {
+ const controller = build();
+ const statuses = controller.sync();
+ const accelerator = statuses[VOICE_CONDUCTOR_HOTKEY_ID].accelerator!;
+ controller.dispose();
+ backend.fire(accelerator);
+
+ expect(floor.presses).toHaveLength(0);
+ });
+});
diff --git a/src/__tests__/main/acappella/ice-config.test.ts b/src/__tests__/main/acappella/ice-config.test.ts
new file mode 100644
index 0000000000..4844e7ba08
--- /dev/null
+++ b/src/__tests__/main/acappella/ice-config.test.ts
@@ -0,0 +1,144 @@
+/**
+ * ICE configuration: what a connection can reach, and what it is called.
+ *
+ * The two things worth pinning down are honesty properties rather than
+ * behaviours. A TURN server switched on with no URL must be off, because the
+ * alternative is an ICE configuration that throws where nobody sees it; and a
+ * candidate pair with a relay on either end must be called relayed, because
+ * saying "direct" would describe a path the audio is not taking.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ CANDIDATE_TYPE_LABELS,
+ DEFAULT_ICE_SETTINGS,
+ DEFAULT_STUN_URLS,
+ TUNNEL_MEDIA_NOTE,
+ buildIceServers,
+ classifyCandidatePair,
+ classifyCandidateType,
+ describeIceReach,
+ iceTransportPolicy,
+ isOverlayAddress,
+ readIceSettings,
+} from '../../../main/acappella/transport/ice-config';
+
+describe('readIceSettings', () => {
+ it('fills in the defaults for an empty store', () => {
+ expect(readIceSettings(undefined)).toEqual(DEFAULT_ICE_SETTINGS);
+ expect(readIceSettings({})).toEqual(DEFAULT_ICE_SETTINGS);
+ });
+
+ it('keeps an explicitly empty STUN list, because that is a real choice', () => {
+ // LAN and overlay only, with nothing reflecting an address off anyone.
+ expect(readIceSettings({ stunUrls: [] }).stunUrls).toEqual([]);
+ });
+
+ it('treats a TURN server with no URL as off, whatever the flag says', () => {
+ const settings = readIceSettings({ turn: { enabled: true, url: ' ' } });
+ expect(settings.turn.enabled).toBe(false);
+ });
+
+ it('does not throw on junk from a settings pane', () => {
+ const settings = readIceSettings({ stunUrls: 'nope', turn: 7, hostCandidates: 'yes' });
+ expect(settings.stunUrls).toEqual([...DEFAULT_STUN_URLS]);
+ expect(settings.turn.enabled).toBe(false);
+ expect(settings.hostCandidates).toBe(true);
+ });
+});
+
+describe('buildIceServers', () => {
+ it('puts TURN last so ICE tries the free paths first', () => {
+ const servers = buildIceServers({
+ ...DEFAULT_ICE_SETTINGS,
+ turn: { enabled: true, url: 'turns:relay.example.com:5349', username: 'u', credential: 'c' },
+ });
+ expect(servers).toHaveLength(2);
+ expect(servers[1]).toMatchObject({ urls: 'turns:relay.example.com:5349', username: 'u' });
+ });
+
+ it('produces nothing at all for a LAN-only configuration', () => {
+ expect(buildIceServers({ ...DEFAULT_ICE_SETTINGS, stunUrls: [] })).toEqual([]);
+ });
+
+ it('forces a relay only when asked', () => {
+ expect(iceTransportPolicy(DEFAULT_ICE_SETTINGS)).toBe('all');
+ expect(iceTransportPolicy({ ...DEFAULT_ICE_SETTINGS, forceRelay: true })).toBe('relay');
+ });
+});
+
+describe('describeIceReach', () => {
+ it('says cellular will not work without TURN', () => {
+ expect(describeIceReach(DEFAULT_ICE_SETTINGS)).toMatch(/cellular will not connect/i);
+ });
+
+ it('says a LAN-only configuration reaches nothing outside it', () => {
+ expect(describeIceReach({ ...DEFAULT_ICE_SETTINGS, stunUrls: [] })).toMatch(
+ /this network and overlay networks only/i
+ );
+ });
+
+ it('names cellular as covered once a relay is configured', () => {
+ const reach = describeIceReach({
+ ...DEFAULT_ICE_SETTINGS,
+ turn: { enabled: true, url: 'turn:r', username: 'u', credential: 'c' },
+ });
+ expect(reach).toMatch(/cellular/i);
+ });
+
+ it('says so when relay-only is on with no relay configured', () => {
+ expect(describeIceReach({ ...DEFAULT_ICE_SETTINGS, forceRelay: true })).toMatch(
+ /no device can connect/i
+ );
+ });
+});
+
+describe('candidate classification', () => {
+ it('collapses the ICE vocabulary onto three words a person can act on', () => {
+ expect(classifyCandidateType('host')).toBe('lan');
+ expect(classifyCandidateType('srflx')).toBe('stun');
+ expect(classifyCandidateType('prflx')).toBe('stun');
+ expect(classifyCandidateType('relay')).toBe('relay');
+ expect(classifyCandidateType(undefined)).toBe('unknown');
+ });
+
+ it('takes the worse end of a pair', () => {
+ expect(classifyCandidatePair('host', 'host')).toBe('lan');
+ expect(classifyCandidatePair('host', 'srflx')).toBe('stun');
+ expect(classifyCandidatePair('host', 'relay')).toBe('relay');
+ expect(classifyCandidatePair('relay', 'host')).toBe('relay');
+ });
+
+ it('reports unknown until both ends are known', () => {
+ expect(classifyCandidatePair('host', undefined)).toBe('unknown');
+ });
+
+ it('has a label for every type', () => {
+ for (const type of ['lan', 'stun', 'relay', 'unknown'] as const) {
+ expect(CANDIDATE_TYPE_LABELS[type]).toEqual(expect.any(String));
+ }
+ });
+});
+
+describe('overlay addresses', () => {
+ it('recognises the CGNAT block Tailscale allocates out of', () => {
+ expect(isOverlayAddress('100.64.0.1')).toBe(true);
+ expect(isOverlayAddress('100.127.255.254')).toBe(true);
+ });
+
+ it('does not mistake an ordinary address for an overlay', () => {
+ expect(isOverlayAddress('192.168.1.10')).toBe(false);
+ expect(isOverlayAddress('100.128.0.1')).toBe(false);
+ expect(isOverlayAddress('not an address')).toBe(false);
+ });
+});
+
+describe('the tunnel note', () => {
+ it('says plainly that the quick tunnel cannot carry the media', () => {
+ // The user who does not know this blames the wrong thing every single time.
+ expect(TUNNEL_MEDIA_NOTE).toMatch(/cloudflare/i);
+ expect(TUNNEL_MEDIA_NOTE).toMatch(/cannot carry/i);
+ expect(TUNNEL_MEDIA_NOTE).toMatch(/turn relay/i);
+ });
+});
diff --git a/src/__tests__/main/acappella/mock-providers.test.ts b/src/__tests__/main/acappella/mock-providers.test.ts
new file mode 100644
index 0000000000..a6fa4e0813
--- /dev/null
+++ b/src/__tests__/main/acappella/mock-providers.test.ts
@@ -0,0 +1,487 @@
+/**
+ * @file mock-providers.test.ts
+ *
+ * Unit tests for the mock provider tier: partial then final ordering out of the
+ * mock STT, deterministic keyword routing out of the mock Brain, and cancellable
+ * sentence streaming out of the mock TTS.
+ *
+ * Resolution rules live in `provider-registry.test.ts`, because they are about
+ * what gets CHOSEN rather than about what the mocks do.
+ *
+ * Every provider is constructed with zero-delay timing so the suite runs
+ * synchronously: the timers are a UX affordance, not behaviour under test.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { MockBrainProvider } from '../../../main/acappella/providers/mock/mock-brain';
+import { MockSttProvider } from '../../../main/acappella/providers/mock/mock-stt';
+import { MockTtsProvider } from '../../../main/acappella/providers/mock/mock-tts';
+import { createMockProviderTrio } from '../../../main/acappella/providers/mock';
+import type { RosterAgent } from '../../../shared/acappella/protocol';
+import type { SttCallbacks, TtsChunk } from '../../../shared/acappella/providers';
+import { splitIntoSpokenSentences } from '../../../shared/acappella/sentences';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeRoster(): RosterAgent[] {
+ return [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [
+ { id: 'tab-auth', name: 'Auth Refactor', lastActiveAt: 1_000 },
+ { id: 'tab-migrations', name: 'DB Migrations', lastActiveAt: 5_000 },
+ ],
+ },
+ {
+ sessionId: 'agent-frontend',
+ name: 'Frontend',
+ agentType: 'codex',
+ cwd: '/repo/web',
+ tabs: [{ id: 'tab-ui', name: 'Sidebar', lastActiveAt: 2_000 }],
+ },
+ ];
+}
+
+/** Records every callback the STT fires, in order. */
+function recordingCallbacks(): {
+ callbacks: SttCallbacks;
+ events: Array<{ kind: 'partial' | 'final' | 'error'; text: string; value: number }>;
+} {
+ const events: Array<{ kind: 'partial' | 'final' | 'error'; text: string; value: number }> = [];
+ return {
+ events,
+ callbacks: {
+ onPartial: (text, stability) => events.push({ kind: 'partial', text, value: stability }),
+ onFinal: (text, confidence) => events.push({ kind: 'final', text, value: confidence }),
+ onError: (error) => events.push({ kind: 'error', text: error.message, value: 0 }),
+ },
+ };
+}
+
+async function collect(iterable: AsyncIterable): Promise {
+ const chunks: TtsChunk[] = [];
+ for await (const chunk of iterable) chunks.push(chunk);
+ return chunks;
+}
+
+// ---------------------------------------------------------------------------
+// Mock STT
+// ---------------------------------------------------------------------------
+
+describe('MockSttProvider', () => {
+ it('emits two partials before the final, in that order', async () => {
+ const stt = new MockSttProvider({ partialDelayMs: 0 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance('open a new tab on the backend agent');
+
+ expect(events.map((event) => event.kind)).toEqual(['partial', 'partial', 'final']);
+ expect(events[2].text).toBe('open a new tab on the backend agent');
+ });
+
+ it('grows the partial hypothesis and its stability', async () => {
+ const stt = new MockSttProvider({ partialDelayMs: 0 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance('one two three four five six');
+
+ const [first, second] = events;
+ expect(second.text.startsWith(first.text)).toBe(true);
+ expect(second.text.length).toBeGreaterThan(first.text.length);
+ expect(second.value).toBeGreaterThan(first.value);
+ });
+
+ it('still emits two partials for a single word', async () => {
+ const stt = new MockSttProvider({ partialDelayMs: 0 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance('stop');
+
+ expect(events.filter((event) => event.kind === 'partial')).toHaveLength(2);
+ });
+
+ it('reports an empty utterance as a final with no partials', async () => {
+ const stt = new MockSttProvider({ partialDelayMs: 0 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance(' ');
+
+ expect(events).toEqual([{ kind: 'final', text: '', value: 1 }]);
+ });
+
+ it('drops pending emissions after stop()', async () => {
+ vi.useFakeTimers();
+ try {
+ const stt = new MockSttProvider({ partialDelayMs: 10 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance('this will be abandoned');
+ await stt.stop();
+ vi.advanceTimersByTime(100);
+
+ expect(events).toHaveLength(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('supersedes a pending utterance rather than interleaving the two', async () => {
+ vi.useFakeTimers();
+ try {
+ const stt = new MockSttProvider({ partialDelayMs: 10 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.injectUtterance('first utterance');
+ vi.advanceTimersByTime(10);
+ stt.injectUtterance('second utterance');
+ vi.advanceTimersByTime(100);
+
+ const finals = events.filter((event) => event.kind === 'final');
+ expect(finals).toHaveLength(1);
+ expect(finals[0].text).toBe('second utterance');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('ignores fed audio instead of inventing a transcript', async () => {
+ const stt = new MockSttProvider({ partialDelayMs: 0 });
+ const { callbacks, events } = recordingCallbacks();
+ await stt.start(callbacks);
+
+ stt.feed(new Int16Array(1024));
+ await stt.flush();
+
+ expect(events).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Mock Brain
+// ---------------------------------------------------------------------------
+
+describe('MockBrainProvider routing', () => {
+ const brain = new MockBrainProvider();
+ const roster = makeRoster();
+
+ it('targets the agent whose name is in the utterance', async () => {
+ const decision = await brain.route('ask backend to run the migrations', {
+ roster,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.target).toEqual({ sessionId: 'agent-backend' });
+ });
+
+ it('targets the conductor when no name is mentioned and nothing is bound', async () => {
+ const decision = await brain.route('what is running right now', {
+ roster,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.target).toBe('conductor');
+ });
+
+ it('falls back to the bound agent when no name is mentioned', async () => {
+ const decision = await brain.route('run the tests', {
+ roster,
+ scope: { kind: 'agent', sessionId: 'agent-frontend' },
+ });
+
+ expect(decision.target).toEqual({ sessionId: 'agent-frontend' });
+ });
+
+ it('ignores a leading "hey maestro" when an agent is called Maestro', async () => {
+ // Found by driving the running app: an agent named "Maestro" is what you
+ // call the agent working on Maestro itself, so it is a very common name,
+ // and "hey maestro" is how you address the conductor. Matching the address
+ // as a name sent every routed sentence to that one agent.
+ const withConductorName: RosterAgent[] = [
+ {
+ sessionId: 'agent-maestro',
+ name: 'Maestro',
+ agentType: 'claude-code',
+ cwd: '/repo/maestro',
+ tabs: [],
+ },
+ ...roster,
+ ];
+
+ const decision = await brain.route('hey maestro ask backend to run the migrations', {
+ roster: withConductorName,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.target).toEqual({ sessionId: 'agent-backend' });
+ });
+
+ it('still targets an agent named Maestro when it is named mid-utterance', async () => {
+ const withConductorName: RosterAgent[] = [
+ {
+ sessionId: 'agent-maestro',
+ name: 'Maestro',
+ agentType: 'claude-code',
+ cwd: '/repo/maestro',
+ tabs: [],
+ },
+ ...roster,
+ ];
+
+ const decision = await brain.route('hey maestro what is maestro working on', {
+ roster: withConductorName,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.target).toEqual({ sessionId: 'agent-maestro' });
+ });
+
+ it('lets a named agent beat the bound one', async () => {
+ const decision = await brain.route('backend, run the tests', {
+ roster,
+ scope: { kind: 'agent', sessionId: 'agent-frontend' },
+ });
+
+ expect(decision.target).toEqual({ sessionId: 'agent-backend' });
+ });
+
+ it('picks new from a new-tab cue and names the tab', async () => {
+ const decision = await brain.route(
+ 'start a new tab on the backend agent about the auth refactor',
+ { roster, scope: { kind: 'conductor' } }
+ );
+
+ expect(decision.target).toEqual({ sessionId: 'agent-backend' });
+ expect(decision.tabAction).toBe('new');
+ expect(decision.prompt).toBe('the auth refactor');
+ expect(decision.tabName).toBe('Auth Refactor');
+ });
+
+ it('picks recall from a back-to cue and resolves the tab by name', async () => {
+ const decision = await brain.route('go back to the auth refactor on backend', {
+ roster,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.tabAction).toBe('recall');
+ expect(decision.tabId).toBe('tab-auth');
+ });
+
+ it('recalls the most recent tab when the utterance names none', async () => {
+ const decision = await brain.route('back to backend', {
+ roster,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.tabAction).toBe('recall');
+ expect(decision.tabId).toBe('tab-migrations');
+ });
+
+ it('downgrades recall to current when the target has no tabs', async () => {
+ const decision = await brain.route('back to the auth one', {
+ roster: [{ ...roster[0], tabs: [] }],
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.tabAction).toBe('current');
+ expect(decision.tabId).toBeUndefined();
+ });
+
+ it('defaults to the current tab with no cue', async () => {
+ const decision = await brain.route('frontend, tighten the sidebar spacing', {
+ roster,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.tabAction).toBe('current');
+ expect(decision.tabName).toBeUndefined();
+ });
+
+ it('is deterministic: the same utterance routes the same way twice', async () => {
+ const context = { roster, scope: { kind: 'conductor' as const } };
+ const first = await brain.route('new tab on frontend about dark mode', context);
+ const second = await brain.route('new tab on frontend about dark mode', context);
+
+ expect(first).toEqual(second);
+ });
+
+ it('scores a named agent with a cue above a bare guess', async () => {
+ const context = { roster, scope: { kind: 'conductor' as const } };
+ const strong = await brain.route('new tab on backend about caching', context);
+ const weak = await brain.route('what changed', context);
+
+ expect(strong.confidence).toBeGreaterThan(weak.confidence);
+ expect(strong.confidence).toBeLessThanOrEqual(1);
+ expect(weak.confidence).toBeGreaterThanOrEqual(0);
+ });
+
+ it('never returns an empty prompt', async () => {
+ const decision = await brain.route('backend', { roster, scope: { kind: 'conductor' } });
+
+ expect(decision.prompt.length).toBeGreaterThan(0);
+ });
+});
+
+describe('MockBrainProvider converse', () => {
+ const brain = new MockBrainProvider();
+ const context = { agentSessionId: 'agent-backend', tabId: 'tab-auth' };
+
+ it('reshapes markdown into at most two spoken sentences', async () => {
+ const spoken = await brain.converse(
+ '## Done\n\nI updated `auth.ts` and **two** tests. The suite is green. One more thing to check later.',
+ context
+ );
+
+ expect(splitIntoSpokenSentences(spoken)).toHaveLength(2);
+ expect(spoken).not.toContain('#');
+ expect(spoken).not.toContain('`');
+ expect(spoken).not.toContain('**');
+ });
+
+ it('honours an explicit sentence budget', async () => {
+ const spoken = await brain.converse('One. Two. Three. Four.', { ...context, maxSentences: 3 });
+
+ expect(splitIntoSpokenSentences(spoken)).toHaveLength(3);
+ });
+
+ it('keeps the sentence count the session announces', async () => {
+ const spoken = await brain.converse(`${'word '.repeat(80)}. Second sentence here.`, context);
+
+ // The service emits `speak-start` with this count and the TTS splits the
+ // same text, so a truncated sentence must not become two.
+ expect(splitIntoSpokenSentences(spoken)).toHaveLength(2);
+ });
+
+ it('returns nothing to speak for empty agent output', async () => {
+ expect(await brain.converse(' ', context)).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Mock TTS
+// ---------------------------------------------------------------------------
+
+describe('MockTtsProvider', () => {
+ it('emits one chunk per sentence, in order', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+ const chunks = await collect(
+ tts.speak('First sentence. Second sentence. Third one.', { utteranceId: 'u1' })
+ );
+
+ expect(chunks.map((chunk) => chunk.text)).toEqual([
+ 'First sentence.',
+ 'Second sentence.',
+ 'Third one.',
+ ]);
+ expect(chunks.map((chunk) => chunk.index)).toEqual([0, 1, 2]);
+ expect(chunks.every((chunk) => chunk.utteranceId === 'u1')).toBe(true);
+ });
+
+ it('splits exactly like the shared splitter the service counted with', async () => {
+ const text = 'Dr. Reed shipped it. The tests pass!';
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+ const chunks = await collect(tts.speak(text, { utteranceId: 'u1' }));
+
+ expect(chunks.map((chunk) => chunk.text)).toEqual(splitIntoSpokenSentences(text));
+ });
+
+ it('carries no audio, so the mock tier is silent by construction', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+ const [chunk] = await collect(tts.speak('Only one.', { utteranceId: 'u1' }));
+
+ expect(chunk.format).toBe('none');
+ expect(chunk.audio).toBeNull();
+ });
+
+ it('stops emitting sentences after cancel()', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+ const seen: string[] = [];
+
+ for await (const chunk of tts.speak('One. Two. Three. Four.', { utteranceId: 'u1' })) {
+ seen.push(chunk.text);
+ if (seen.length === 2) tts.cancel();
+ }
+
+ expect(seen).toEqual(['One.', 'Two.']);
+ });
+
+ it('cuts the in-flight sentence delay short instead of waiting it out', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 50, minSentenceMs: 5_000 });
+ const seen: string[] = [];
+
+ const run = (async () => {
+ for await (const chunk of tts.speak('One. Two. Three.', { utteranceId: 'u1' })) {
+ seen.push(chunk.text);
+ tts.cancel();
+ }
+ })();
+
+ // Real timers: if cancel() did not wake the sleep this would hang for 5s.
+ await run;
+ expect(seen).toEqual(['One.']);
+ });
+
+ it('supersedes a previous run so its stragglers are dropped', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+ const first = tts.speak('One. Two. Three.', { utteranceId: 'u1' })[Symbol.asyncIterator]();
+
+ await first.next();
+ const second = await collect(tts.speak('Fresh run.', { utteranceId: 'u2' }));
+
+ expect((await first.next()).done).toBe(true);
+ expect(second.map((chunk) => chunk.text)).toEqual(['Fresh run.']);
+ });
+
+ it('yields nothing for text with no sentences in it', async () => {
+ const tts = new MockTtsProvider({ msPerCharacter: 0 });
+
+ expect(await collect(tts.speak(' ', { utteranceId: 'u1' }))).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Registry
+// ---------------------------------------------------------------------------
+// Trio wiring
+// ---------------------------------------------------------------------------
+
+describe('createMockProviderTrio', () => {
+ it('drives an utterance from typed text through to spoken sentences', async () => {
+ const trio = createMockProviderTrio({ stt: { partialDelayMs: 0 }, tts: { msPerCharacter: 0 } });
+ const { callbacks, events } = recordingCallbacks();
+ await trio.stt.start(callbacks);
+
+ trio.stt.injectUtterance?.('new tab on backend about the auth refactor');
+ const final = events.at(-1);
+ expect(final?.kind).toBe('final');
+
+ const decision = await trio.brain.route(final!.text, {
+ roster: makeRoster(),
+ scope: { kind: 'conductor' },
+ });
+ expect(decision.tabAction).toBe('new');
+
+ const spoken = await trio.brain.converse('Opened it. The refactor branch is checked out.', {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-auth',
+ });
+ const chunks = await collect(trio.tts.speak(spoken, { utteranceId: 'u1' }));
+
+ expect(chunks).toHaveLength(splitIntoSpokenSentences(spoken).length);
+ });
+});
diff --git a/src/__tests__/main/acappella/models/capability-gate.test.ts b/src/__tests__/main/acappella/models/capability-gate.test.ts
new file mode 100644
index 0000000000..1db8b5d669
--- /dev/null
+++ b/src/__tests__/main/acappella/models/capability-gate.test.ts
@@ -0,0 +1,661 @@
+/**
+ * @file capability-gate.test.ts
+ *
+ * The gate has one job with two halves: say exactly why a slot is not ready, and
+ * never, under any configuration, hand back a different provider than the one
+ * that was asked for.
+ *
+ * The second half is the load-bearing one. Routing audio to a cloud API the user
+ * did not pick is an unasked-for charge and a privacy break, so the last test in
+ * this file walks every combination of provider selection and disk state and
+ * asserts that no verdict ever names a provider other than the configured one.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+/**
+ * Which runtimes this "build" ships, flipped per test.
+ *
+ * The gate now asks the loader whether a runtime will load at all, and the real
+ * registry has every one at `declared: false` until the providers land. Against
+ * that, every test below would block on the runtime and never reach the provider
+ * and model logic they exist to cover. Declared by default, so a test that cares
+ * about the runtime dimension is the one that says so.
+ */
+// `vi.hoisted` because the mock factory runs during the import phase, before a
+// plain `const` at this scope has been initialised.
+const { declared } = vi.hoisted(() => ({
+ declared: { llama: true, whisper: true, onnx: true } as Record,
+}));
+
+vi.mock('../../../../shared/acappella/native-runtimes', () => {
+ const descriptor = (id: string, moduleId: string, label: string) => ({
+ id,
+ moduleId,
+ versionPin: '1.0.0',
+ label,
+ slots: [],
+ get declared() {
+ return declared[id];
+ },
+ requiresElectronRebuild: false,
+ prebuilds: {
+ 'darwin-arm64': 'prebuilt',
+ 'darwin-x64': 'prebuilt',
+ 'win32-x64': 'prebuilt',
+ 'linux-x64': 'prebuilt',
+ },
+ asarUnpack: [],
+ packagedBinaries: {
+ 'darwin-arm64': [],
+ 'darwin-x64': [],
+ 'win32-x64': [],
+ 'linux-x64': [],
+ },
+ rationale: '',
+ notes: '',
+ });
+
+ const runtimes = [
+ descriptor('llama', 'fake-llama', 'Fake llama'),
+ descriptor('whisper', 'fake-whisper', 'Fake whisper'),
+ descriptor('onnx', 'fake-onnx', 'Fake onnx'),
+ ];
+
+ return {
+ NATIVE_RUNTIMES: runtimes,
+ getNativeRuntime: (id: string) => runtimes.find((runtime) => runtime.id === id),
+ nativePlatformKey: (platform: string, arch: string) => {
+ const key = `${platform}-${arch}`;
+ return ['darwin-arm64', 'darwin-x64', 'win32-x64', 'linux-x64'].includes(key) ? key : null;
+ },
+ };
+});
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/acappella-capability-gate-test' },
+ shell: { openExternal: vi.fn() },
+ // The gate reads the microphone permission, which is a pure query and never
+ // prompts. Granted here so these tests stay about providers and models; the
+ // permission's own behaviour is covered in mic-permission.test.ts.
+ systemPreferences: {
+ getMediaAccessStatus: () => 'granted',
+ askForMediaAccess: vi.fn(),
+ },
+}));
+
+import {
+ LOCAL_PROVIDER_IDS,
+ WAKE_WORD_PROVIDER_ID,
+ resolveVoiceReadiness,
+} from '../../../../main/acappella/models/capability-gate';
+import type { ModelStatus } from '../../../../main/acappella/models/model-store';
+import {
+ VOICE_SLOT_UNSATISFIED_REASONS,
+ readinessErrorMessage,
+ type VoiceReadiness,
+ type VoiceSlot,
+ type VoiceSlotReadiness,
+ type VoiceSlotUnsatisfiedReason,
+} from '../../../../shared/acappella/readiness';
+import type { NativeRuntimeUnavailable } from '../../../../main/acappella/runtime/native-loader';
+import {
+ KOKORO_82M_ID,
+ OPENWAKEWORD_BASE_ID,
+ QWEN3_1_7B_ID,
+ WHISPER_BASE_EN_ID,
+} from '../../../../shared/acappella/model-catalog';
+
+type StatusKind = ModelStatus['status'];
+
+/** A fake store: every model reports whatever the map says, installed by default. */
+function statusReader(overrides: Record = {}) {
+ return async (modelId: string): Promise => {
+ const status = overrides[modelId] ?? 'installed';
+ return {
+ id: modelId,
+ status,
+ manifest: null,
+ detail: status === 'corrupt' ? 'hash mismatch' : undefined,
+ bytesOnDisk: status === 'not-installed' ? 0 : 1024,
+ };
+ };
+}
+
+const ALL_LOCAL = {
+ stt: LOCAL_PROVIDER_IDS.stt,
+ tts: LOCAL_PROVIDER_IDS.tts,
+ brain: LOCAL_PROVIDER_IDS.brain,
+};
+
+describe('capability-gate', () => {
+ describe('satisfied slots', () => {
+ it('is ready when every local model is installed', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ });
+
+ expect(readiness.canStartSession).toBe(true);
+ expect(readiness.canRunHandsFree).toBe(true);
+ expect(readiness.blocking).toHaveLength(0);
+ // The microphone leads: it is the one requirement that holds regardless of
+ // which providers are configured, and a user who reads "microphone access
+ // denied" first does not need to read the rest.
+ expect(readiness.slots.map((slot) => slot.slot)).toEqual([
+ 'microphone',
+ 'stt',
+ 'tts',
+ 'brain',
+ 'wake-word',
+ ]);
+ });
+
+ it('treats the mock tier as satisfied, since it needs nothing', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { stt: 'mock-stt', tts: 'mock-tts', brain: 'mock-brain' },
+ readModelStatus: statusReader({
+ [WHISPER_BASE_EN_ID]: 'not-installed',
+ [KOKORO_82M_ID]: 'not-installed',
+ [QWEN3_1_7B_ID]: 'not-installed',
+ }),
+ });
+
+ expect(readiness.canStartSession).toBe(true);
+ });
+ });
+
+ describe('every unsatisfied reason', () => {
+ it('reports model-not-installed with a download action', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({ [WHISPER_BASE_EN_ID]: 'not-installed' }),
+ });
+
+ const stt = readiness.slots.find((slot) => slot.slot === 'stt')!;
+ expect(stt.satisfied).toBe(false);
+ expect(stt.reason).toBe('model-not-installed');
+ expect(stt.requiredModelId).toBe(WHISPER_BASE_EN_ID);
+ expect(stt.detail).toContain('is not installed');
+ expect(stt.suggestedAction).toContain('Download');
+ expect(readiness.canStartSession).toBe(false);
+ });
+
+ it('reports model-corrupt with a re-verify action', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({ [KOKORO_82M_ID]: 'corrupt' }),
+ });
+
+ const tts = readiness.slots.find((slot) => slot.slot === 'tts')!;
+ expect(tts.reason).toBe('model-corrupt');
+ expect(tts.detail).toContain('failed verification');
+ expect(tts.detail).toContain('hash mismatch');
+ expect(tts.suggestedAction).toContain('Re-verify');
+ });
+
+ it('reports api-key-missing for a cloud provider with no key', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, tts: 'elevenlabs-tts' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => false,
+ });
+
+ const tts = readiness.slots.find((slot) => slot.slot === 'tts')!;
+ expect(tts.reason).toBe('api-key-missing');
+ expect(tts.detail).toContain('ElevenLabs');
+ // The honest alternative is named, because it is often the real recovery.
+ expect(tts.suggestedAction).toContain('local model');
+ });
+
+ it('treats an absent stored key as missing', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, brain: 'openai-realtime' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => false,
+ });
+
+ expect(readiness.slots.find((slot) => slot.slot === 'brain')?.reason).toBe('api-key-missing');
+ });
+
+ it('reports provider-unreachable when the probe says so', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, brain: 'openai-realtime' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => true,
+ probeProvider: () => false,
+ });
+
+ const brain = readiness.slots.find((slot) => slot.slot === 'brain')!;
+ expect(brain.reason).toBe('provider-unreachable');
+ expect(brain.detail).toContain('could not be reached');
+ });
+
+ it('reports runtime-unavailable, and reports it INSTEAD of a download', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ // Model missing AND runtime broken: the runtime wins, because
+ // downloading 1.1 GB does not fix a binary that will not load.
+ readModelStatus: statusReader({ [QWEN3_1_7B_ID]: 'not-installed' }),
+ readRuntimeFailure: (runtimeId) =>
+ runtimeId === 'llama'
+ ? {
+ kind: 'runtime-unavailable',
+ runtimeId: 'llama',
+ moduleId: 'node-llama-cpp',
+ platform: 'linux',
+ arch: 'x64',
+ failure: 'load-failed',
+ message: 'llama.cpp failed to load on linux-x64.',
+ suggestedAction: 'Run the voice self-test and include the result.',
+ }
+ : null,
+ });
+
+ const brain = readiness.slots.find((slot) => slot.slot === 'brain')!;
+ expect(brain.reason).toBe('runtime-unavailable');
+ expect(brain.detail).toContain('failed to load');
+ expect(brain.suggestedAction).toContain('self-test');
+ expect(readiness.canStartSession).toBe(false);
+ });
+
+ it('leaves a slot alone when its runtime has never failed', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readRuntimeFailure: () => null,
+ });
+
+ expect(readiness.canStartSession).toBe(true);
+ });
+
+ it('blocks a runtime that is not in this build, before anything tries to load it', async () => {
+ // The default reader, deliberately: a gate that only knows about loads
+ // that have already been attempted says "ready" on a fresh boot for a
+ // runtime the build does not contain, and the user finds out when the
+ // session dies. Every model is on disk here; the runtime still decides.
+ declared.whisper = false;
+ try {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ });
+
+ const stt = readiness.slots.find((slot) => slot.slot === 'stt')!;
+ expect(stt.satisfied).toBe(false);
+ expect(stt.reason).toBe('runtime-unavailable');
+ expect(stt.detail).toContain('not part of this build');
+ expect(readiness.canStartSession).toBe(false);
+ } finally {
+ declared.whisper = true;
+ }
+ });
+
+ it('assumes reachable when no probe is wired', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, brain: 'openai-realtime' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => true,
+ });
+
+ expect(readiness.canStartSession).toBe(true);
+ });
+ });
+
+ describe('the wake word', () => {
+ it('blocks hands-free but not a click-to-talk session', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({ [OPENWAKEWORD_BASE_ID]: 'not-installed' }),
+ });
+
+ expect(readiness.canStartSession).toBe(true);
+ expect(readiness.canRunHandsFree).toBe(false);
+ expect(readiness.blocking).toHaveLength(0);
+ const wake = readiness.slots.find((slot) => slot.slot === 'wake-word')!;
+ expect(wake.satisfied).toBe(false);
+ expect(wake.providerId).toBe(WAKE_WORD_PROVIDER_ID);
+ });
+
+ it('is always local: no setting can point it at a cloud provider', async () => {
+ const readiness = await resolveVoiceReadiness({
+ // Deliberately hostile settings: nothing here may reach the wake word.
+ settings: { stt: 'openai-realtime', tts: 'elevenlabs-tts', brain: 'openai-realtime' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => true,
+ });
+
+ expect(readiness.slots.find((slot) => slot.slot === 'wake-word')?.providerId).toBe(
+ WAKE_WORD_PROVIDER_ID
+ );
+ });
+ });
+
+ describe('no implicit provider substitution', () => {
+ const providerChoices = [
+ LOCAL_PROVIDER_IDS.stt,
+ 'openai-realtime',
+ 'mock-stt',
+ 'not-a-registered-provider',
+ ];
+ const diskStates: StatusKind[] = ['installed', 'not-installed', 'corrupt'];
+
+ it('never names a provider other than the one configured', async () => {
+ for (const stt of providerChoices) {
+ for (const disk of diskStates) {
+ for (const key of [false, true]) {
+ for (const reachable of [true, false]) {
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, stt },
+ readModelStatus: statusReader({
+ [WHISPER_BASE_EN_ID]: disk,
+ [KOKORO_82M_ID]: disk,
+ [QWEN3_1_7B_ID]: disk,
+ [OPENWAKEWORD_BASE_ID]: disk,
+ }),
+ hasApiKey: () => key,
+ probeProvider: () => reachable,
+ });
+
+ const sttSlot = readiness.slots.find((slot) => slot.slot === 'stt')!;
+ // The verdict reports what was ASKED for. If the gate ever
+ // "helpfully" resolved to a working provider, this is where it
+ // would show up.
+ expect(sttSlot.providerId).toBe(stt);
+
+ // And an unsatisfied slot is never quietly satisfied by another.
+ if (!sttSlot.satisfied) {
+ expect(readiness.canStartSession).toBe(false);
+ expect(readiness.blocking.map((slot) => slot.slot)).toContain('stt');
+ }
+ }
+ }
+ }
+ }
+ });
+
+ it('leaves an unknown provider satisfied only because it demands nothing', async () => {
+ // An id the gate has never heard of has no requirement to check, so it
+ // cannot be reported as blocked here. It is the registry, not the gate,
+ // that refuses to run it - and the registry's only fallback is the mock.
+ const readiness = await resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, brain: 'not-a-registered-provider' },
+ readModelStatus: statusReader(),
+ });
+
+ const brain = readiness.slots.find((slot) => slot.slot === 'brain')!;
+ expect(brain.providerId).toBe('not-a-registered-provider');
+ expect(brain.satisfied).toBe(true);
+ });
+ });
+
+ describe('the microphone slot', () => {
+ it('reports mic-permission-denied as a permission, with the privacy pane as the fix', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'denied',
+ });
+
+ const mic = readiness.slots.find((slot) => slot.slot === 'microphone')!;
+ expect(mic.satisfied).toBe(false);
+ expect(mic.reason).toBe('mic-permission-denied');
+ // Named as a permission, never as "voice unavailable": a user with every
+ // model on disk and a denied microphone has a one-checkbox problem.
+ expect(mic.detail).toMatch(/microphone access/i);
+ expect(mic.suggestedAction).toMatch(/privacy settings/i);
+ expect(readiness.canStartSession).toBe(false);
+ });
+
+ it('reports mic-permission-restricted without sending the user to a checkbox they cannot tick', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'restricted',
+ });
+
+ const mic = readiness.slots.find((slot) => slot.slot === 'microphone')!;
+ expect(mic.reason).toBe('mic-permission-restricted');
+ expect(mic.suggestedAction).toMatch(/manages this machine/i);
+ expect(mic.suggestedAction).not.toMatch(/privacy settings/i);
+ });
+
+ it.each(['granted', 'not-determined', 'unknown'] as const)(
+ 'does not block on %s, which is a machine that has not been asked yet',
+ async (permission) => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readMicPermission: () => permission,
+ });
+
+ const mic = readiness.slots.find((slot) => slot.slot === 'microphone')!;
+ expect(mic.satisfied).toBe(true);
+ // Reported even when satisfied, so Voice Setup can say "you will be asked
+ // when you start" instead of describing it as a problem.
+ expect(mic.micPermission).toBe(permission);
+ }
+ );
+ });
+
+ describe('no unsatisfied slot is a dead end', () => {
+ /** A runtime that has already failed to load in this process. */
+ const whisperRuntimeFailure: NativeRuntimeUnavailable = {
+ kind: 'runtime-unavailable',
+ runtimeId: 'whisper',
+ moduleId: 'whisper-node',
+ platform: 'darwin',
+ arch: 'arm64',
+ failure: 'load-failed',
+ message: 'whisper.cpp could not be loaded on this machine.',
+ suggestedAction: 'Reinstall Maestro, or switch Speech-to-Text to a hosted provider.',
+ detail: 'dlopen failed',
+ };
+
+ /**
+ * Every reason in the union, each produced by a real configuration.
+ *
+ * "Voice mode unavailable" with no reason is indistinguishable from a bug,
+ * so the gate's contract is that an unsatisfied slot ALWAYS carries a
+ * sentence naming the missing piece and a sentence saying what to do. A new
+ * reason added without either would otherwise reach a user as a disabled
+ * button with nothing next to it.
+ */
+ const CASES: Array<[VoiceSlotUnsatisfiedReason, () => Promise]> = [
+ [
+ 'model-not-installed',
+ () =>
+ resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({ [WHISPER_BASE_EN_ID]: 'not-installed' }),
+ }),
+ ],
+ [
+ 'model-corrupt',
+ () =>
+ resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({ [KOKORO_82M_ID]: 'corrupt' }),
+ }),
+ ],
+ [
+ 'api-key-missing',
+ () =>
+ resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, stt: 'openai-stt' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => false,
+ }),
+ ],
+ [
+ 'provider-unreachable',
+ () =>
+ resolveVoiceReadiness({
+ settings: { ...ALL_LOCAL, stt: 'openai-stt' },
+ readModelStatus: statusReader(),
+ hasApiKey: () => true,
+ probeProvider: () => false,
+ }),
+ ],
+ [
+ 'runtime-unavailable',
+ () =>
+ resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readRuntimeFailure: (runtimeId) =>
+ runtimeId === 'whisper' ? whisperRuntimeFailure : null,
+ }),
+ ],
+ [
+ 'mic-permission-denied',
+ () =>
+ resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'denied',
+ }),
+ ],
+ [
+ 'mic-permission-restricted',
+ () =>
+ resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'restricted',
+ }),
+ ],
+ ];
+
+ it('covers every reason in the union, so a new one cannot be added silently', () => {
+ expect(CASES.map(([reason]) => reason).sort()).toEqual(
+ [...VOICE_SLOT_UNSATISFIED_REASONS].sort()
+ );
+ });
+
+ it.each(CASES)('%s carries a detail and a recovery', async (reason, resolve) => {
+ const readiness = await resolve();
+ const slot = readiness.slots.find((entry: VoiceSlotReadiness) => entry.reason === reason);
+
+ expect(slot, `no slot reported ${reason}`).toBeDefined();
+ expect(slot!.satisfied).toBe(false);
+ expect(slot!.detail).toEqual(expect.any(String));
+ expect(slot!.detail!.length).toBeGreaterThan(0);
+ expect(slot!.suggestedAction).toEqual(expect.any(String));
+ expect(slot!.suggestedAction!.length).toBeGreaterThan(0);
+ // The recovery has to be a different sentence from the diagnosis, or it is
+ // not a recovery.
+ expect(slot!.suggestedAction).not.toBe(slot!.detail);
+ });
+ });
+
+ describe('readinessErrorMessage', () => {
+ it('names every blocking slot and its recovery', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader({
+ [WHISPER_BASE_EN_ID]: 'not-installed',
+ [QWEN3_1_7B_ID]: 'corrupt',
+ }),
+ });
+
+ const message = readinessErrorMessage(readiness);
+ expect(message).toContain('Speech-to-Text');
+ expect(message).toContain('Conductor Brain');
+ expect(message).toContain('Download it in Settings');
+ expect(message).toContain('Re-verify');
+ });
+
+ it('is empty when nothing is blocking', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: ALL_LOCAL,
+ readModelStatus: statusReader(),
+ });
+ expect(readinessErrorMessage(readiness)).toBe('');
+ });
+
+ it('states one shared recovery once, not once per slot', () => {
+ // The real shape of a build without the native runtimes: three slots fail
+ // together with the same fix. Repeating it three times was two thirds of
+ // the message and read as a wall rather than as an instruction.
+ const action = 'Use a hosted provider or the mock tier until the local runtime ships.';
+ const message = readinessErrorMessage(
+ blockedOn(
+ (
+ [
+ { slot: 'stt', detail: 'Speech-to-Text: whisper.cpp is not part of this build yet.' },
+ {
+ slot: 'tts',
+ detail: 'Text-to-Speech: ONNX Runtime is not part of this build yet.',
+ },
+ {
+ slot: 'brain',
+ detail: 'Conductor Brain: llama.cpp is not part of this build yet.',
+ },
+ ] as const
+ ).map((slot) => ({ ...slot, suggestedAction: action }))
+ )
+ );
+
+ expect(message.split(action)).toHaveLength(2); // stated exactly once
+ expect(message).toContain('whisper.cpp');
+ expect(message).toContain('ONNX Runtime');
+ expect(message).toContain('llama.cpp');
+ expect(message.endsWith(action)).toBe(true);
+ });
+
+ it('keeps recoveries per slot when they differ', () => {
+ // A denied microphone and a missing model are two problems with two
+ // different next steps; collapsing them would drop one of the fixes.
+ const message = readinessErrorMessage(
+ blockedOn([
+ {
+ slot: 'microphone',
+ detail: 'Microphone: Maestro does not have microphone access.',
+ suggestedAction: 'Grant microphone access in your system privacy settings.',
+ },
+ {
+ slot: 'stt',
+ detail: 'Speech-to-Text: Whisper Base is not installed.',
+ suggestedAction: 'Download it in Settings.',
+ },
+ ])
+ );
+
+ expect(message).toContain('Grant microphone access');
+ expect(message).toContain('Download it in Settings');
+ });
+
+ it('does not lend one slot recovery to a slot that has none', () => {
+ const action = 'Download it in Settings.';
+ const message = readinessErrorMessage(
+ blockedOn([
+ {
+ slot: 'stt',
+ detail: 'Speech-to-Text: Whisper Base is not installed.',
+ suggestedAction: action,
+ },
+ { slot: 'tts', detail: 'Text-to-Speech: something went wrong.' },
+ ])
+ );
+
+ // Hoisting here would read as though the second slot were fixed by the
+ // first one's action. It stays attached to the slot that stated it.
+ expect(message).toBe(
+ `Speech-to-Text: Whisper Base is not installed. ${action} Text-to-Speech: something went wrong.`
+ );
+ });
+ });
+});
+
+/** A readiness verdict blocked on exactly these slots. */
+function blockedOn(
+ blocking: Array & { slot: VoiceSlot }>
+): VoiceReadiness {
+ const slots = blocking.map((slot) => ({
+ providerId: 'test-provider',
+ satisfied: false as const,
+ ...slot,
+ })) as VoiceSlotReadiness[];
+ return { canStartSession: false, canRunHandsFree: false, slots, blocking: slots };
+}
diff --git a/src/__tests__/main/acappella/models/model-downloader.test.ts b/src/__tests__/main/acappella/models/model-downloader.test.ts
new file mode 100644
index 0000000000..a795be3bab
--- /dev/null
+++ b/src/__tests__/main/acappella/models/model-downloader.test.ts
@@ -0,0 +1,346 @@
+/**
+ * @file model-downloader.test.ts
+ *
+ * The downloader's contract in four properties:
+ *
+ * - a resumed download CONTINUES from the `.part` rather than restarting,
+ * - a hash mismatch is rejected, the `.part` is deleted, and both hashes are
+ * reported,
+ * - cancel leaves nothing behind,
+ * - the final file only ever appears AFTER verification.
+ *
+ * The transport is injected, so nothing here touches the network. The catalog is
+ * replaced with one tiny entry whose real SHA-256 is computed in the test, which
+ * is the only way to exercise the success path without a 141 MB download.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+const tempRoot = { dir: '' };
+
+vi.mock('electron', () => ({
+ app: { getPath: () => tempRoot.dir },
+}));
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+const fixture = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { createHash } = require('crypto') as typeof import('crypto');
+ const contents = Buffer.from('a cappella downloader fixture payload, long enough to slice');
+ return {
+ contents,
+ id: 'test-tiny-model',
+ url: 'https://example.invalid/tiny.bin',
+ sha256: createHash('sha256').update(contents).digest('hex'),
+ bytes: contents.length,
+ };
+});
+
+vi.mock('../../../../shared/acappella/model-catalog', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const testEntry = Object.freeze({
+ id: fixture.id,
+ displayName: 'Test Tiny Model',
+ role: 'stt' as const,
+ repo: 'maestro/test',
+ revision: '0000000000000000000000000000000000000000',
+ license: 'MIT',
+ licenseUrl: 'https://example.invalid/license',
+ requiredFor: 'local-speech-to-text' as const,
+ description: 'Fixture entry, present only under test.',
+ files: Object.freeze([
+ {
+ path: 'tiny.bin',
+ sourceUrl: fixture.url,
+ sha256: fixture.sha256,
+ bytes: fixture.bytes,
+ },
+ ]),
+ bytes: fixture.bytes,
+ });
+ const catalog = Object.freeze([...actual.VOICE_MODEL_CATALOG, testEntry]);
+ const byId = new Map(catalog.map((entry) => [entry.id, entry]));
+ return {
+ ...actual,
+ VOICE_MODEL_CATALOG: catalog,
+ getVoiceModel: (id: string) => byId.get(id),
+ isVoiceModelId: (id: string) => byId.has(id),
+ };
+});
+
+import {
+ ModelDownloader,
+ type DownloadProgress,
+ type FetchLike,
+} from '../../../../main/acappella/models/model-downloader';
+import {
+ modelDir,
+ modelFilePath,
+ readManifest,
+} from '../../../../main/acappella/models/model-store';
+
+const FILE_PATH = 'tiny.bin';
+
+function finalPath(): string {
+ return modelFilePath(fixture.id, FILE_PATH);
+}
+
+function partPath(): string {
+ return `${finalPath()}.part`;
+}
+
+async function exists(target: string): Promise {
+ try {
+ await fs.stat(target);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** A fetch that serves `body` and honours `Range: bytes=N-`. */
+function rangeAwareFetch(
+ body: Buffer,
+ record: { ranges: string[] } = { ranges: [] }
+): { fetchImpl: FetchLike; record: { ranges: string[] } } {
+ const fetchImpl: FetchLike = async (_url, init) => {
+ const range = (init?.headers as Record | undefined)?.Range;
+ record.ranges.push(range ?? '');
+ if (!range) return new Response(new Uint8Array(body), { status: 200 });
+ const start = Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0);
+ return new Response(new Uint8Array(body.subarray(start)), { status: 206 });
+ };
+ return { fetchImpl, record };
+}
+
+function makeDownloader(fetchImpl: FetchLike): ModelDownloader {
+ return new ModelDownloader({ fetchImpl, retryDelayMs: () => 0, progressIntervalMs: 0 });
+}
+
+describe('model-downloader', () => {
+ let previousUserData: string | undefined;
+
+ beforeEach(async () => {
+ tempRoot.dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-downloader-'));
+ // MAESTRO_USER_DATA wins over the electron mock in `dataDir()`, and a
+ // Maestro-run agent has it pointed at the live install.
+ previousUserData = process.env.MAESTRO_USER_DATA;
+ process.env.MAESTRO_USER_DATA = tempRoot.dir;
+ });
+
+ afterEach(async () => {
+ if (previousUserData === undefined) delete process.env.MAESTRO_USER_DATA;
+ else process.env.MAESTRO_USER_DATA = previousUserData;
+ await fs.rm(tempRoot.dir, { recursive: true, force: true });
+ });
+
+ it('downloads, verifies, and only then renames into place', async () => {
+ const seenDuringTransfer: boolean[] = [];
+ const fetchImpl: FetchLike = async () => {
+ // The final path must not exist while bytes are still arriving.
+ seenDuringTransfer.push(await exists(finalPath()));
+ return new Response(new Uint8Array(fixture.contents), { status: 200 });
+ };
+
+ const downloader = makeDownloader(fetchImpl);
+ const result = await downloader.download(fixture.id);
+
+ expect(result.status).toBe('complete');
+ expect(seenDuringTransfer).toEqual([false]);
+ expect(await exists(finalPath())).toBe(true);
+ expect(await exists(partPath())).toBe(false);
+
+ const manifest = await readManifest(fixture.id);
+ expect(manifest?.id).toBe(fixture.id);
+ expect(manifest?.bytes).toBe(fixture.bytes);
+ });
+
+ it('resumes from a partial .part instead of restarting', async () => {
+ const { fetchImpl, record } = rangeAwareFetch(fixture.contents);
+
+ // Simulate a killed app: the first 20 bytes are already on disk.
+ await fs.mkdir(modelDir(fixture.id), { recursive: true });
+ await fs.writeFile(partPath(), fixture.contents.subarray(0, 20));
+
+ const downloader = makeDownloader(fetchImpl);
+ const result = await downloader.download(fixture.id);
+
+ expect(result.status).toBe('complete');
+ expect(record.ranges).toEqual(['bytes=20-']);
+ // The digest has to cover the resumed bytes too, or the completed file
+ // would fail verification despite being byte-perfect.
+ expect(await fs.readFile(finalPath())).toEqual(fixture.contents);
+ });
+
+ it('restarts when the server ignores the range header', async () => {
+ const requests: string[] = [];
+ const fetchImpl: FetchLike = async (_url, init) => {
+ requests.push((init?.headers as Record | undefined)?.Range ?? '');
+ // 200, not 206: the whole file, despite the range request.
+ return new Response(new Uint8Array(fixture.contents), { status: 200 });
+ };
+
+ await fs.mkdir(modelDir(fixture.id), { recursive: true });
+ await fs.writeFile(partPath(), fixture.contents.subarray(0, 20));
+
+ const result = await makeDownloader(fetchImpl).download(fixture.id);
+
+ expect(requests).toEqual(['bytes=20-']);
+ expect(result.status).toBe('complete');
+ // Appending onto the stale partial would have produced 20 extra bytes.
+ expect(await fs.readFile(finalPath())).toEqual(fixture.contents);
+ });
+
+ it('rejects a hash mismatch, deletes the .part, and reports both hashes', async () => {
+ const corrupted = Buffer.from(fixture.contents);
+ corrupted[0] = corrupted[0] ^ 0xff;
+ const fetchImpl: FetchLike = async () =>
+ new Response(new Uint8Array(corrupted), { status: 200 });
+
+ const downloader = makeDownloader(fetchImpl);
+ const progress: DownloadProgress[] = [];
+ downloader.onProgress((event) => progress.push(event));
+
+ const result = await downloader.download(fixture.id);
+
+ expect(result.status).toBe('error');
+ expect(result.mismatch?.expected).toBe(fixture.sha256);
+ expect(result.mismatch?.actual).not.toBe(fixture.sha256);
+ // Nothing verified, so nothing may be at the final path, and the bad bytes
+ // must not survive to be "resumed" forever.
+ expect(await exists(finalPath())).toBe(false);
+ expect(await exists(partPath())).toBe(false);
+ expect(await readManifest(fixture.id)).toBeNull();
+ expect(progress.at(-1)?.phase).toBe('error');
+ });
+
+ it('cancel leaves no stray files', async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ const fetchImpl: FetchLike = async () => {
+ const stream = new ReadableStream({
+ async start(controller) {
+ controller.enqueue(new Uint8Array(fixture.contents.subarray(0, 10)));
+ await gate;
+ controller.close();
+ },
+ });
+ return new Response(stream, { status: 200 });
+ };
+
+ const downloader = makeDownloader(fetchImpl);
+ const running = downloader.download(fixture.id);
+ // Let the first chunk land so there is something on disk to clean up.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ const cancelPromise = downloader.cancel(fixture.id);
+ release();
+ await cancelPromise;
+ const result = await running;
+
+ expect(result.status).toBe('cancelled');
+ expect(await exists(partPath())).toBe(false);
+ expect(await exists(finalPath())).toBe(false);
+ expect(await exists(modelDir(fixture.id))).toBe(false);
+ });
+
+ it('pause keeps the partial file so the next start resumes', async () => {
+ let release: () => void = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ let served = 0;
+
+ const fetchImpl: FetchLike = async (_url, init) => {
+ served++;
+ const range = (init?.headers as Record | undefined)?.Range;
+ if (served === 1) {
+ const stream = new ReadableStream({
+ async start(controller) {
+ controller.enqueue(new Uint8Array(fixture.contents.subarray(0, 12)));
+ await gate;
+ controller.close();
+ },
+ });
+ return new Response(stream, { status: 200 });
+ }
+ const start = Number(/bytes=(\d+)-/.exec(range ?? '')?.[1] ?? 0);
+ return new Response(new Uint8Array(fixture.contents.subarray(start)), { status: 206 });
+ };
+
+ const downloader = makeDownloader(fetchImpl);
+ const running = downloader.download(fixture.id);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ expect(downloader.pause(fixture.id)).toBe(true);
+ release();
+ const paused = await running;
+
+ expect(paused.status).toBe('paused');
+ expect(await exists(partPath())).toBe(true);
+ expect(await exists(finalPath())).toBe(false);
+
+ const resumed = await downloader.resume(fixture.id);
+ expect(resumed.status).toBe('complete');
+ expect(await fs.readFile(finalPath())).toEqual(fixture.contents);
+ });
+
+ it('retries a transient server error and then succeeds', async () => {
+ let attempts = 0;
+ const fetchImpl: FetchLike = async () => {
+ attempts++;
+ if (attempts < 3) return new Response('boom', { status: 503 });
+ return new Response(new Uint8Array(fixture.contents), { status: 200 });
+ };
+
+ const result = await makeDownloader(fetchImpl).download(fixture.id);
+ expect(result.status).toBe('complete');
+ expect(attempts).toBe(3);
+ });
+
+ it('does not retry a 404', async () => {
+ let attempts = 0;
+ const fetchImpl: FetchLike = async () => {
+ attempts++;
+ return new Response('nope', { status: 404 });
+ };
+
+ const result = await makeDownloader(fetchImpl).download(fixture.id);
+ expect(result.status).toBe('error');
+ expect(result.error).toContain('404');
+ expect(attempts).toBe(1);
+ });
+
+ it('rejects an unknown model without touching the transport', async () => {
+ const fetchImpl = vi.fn();
+ const result = await makeDownloader(fetchImpl).download('not-a-model');
+ expect(result.status).toBe('error');
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('reports bytes, total, and a rate on progress', async () => {
+ const fetchImpl: FetchLike = async () =>
+ new Response(new Uint8Array(fixture.contents), { status: 200 });
+
+ const downloader = makeDownloader(fetchImpl);
+ const events: DownloadProgress[] = [];
+ downloader.onProgress((event) => events.push(event));
+
+ await downloader.download(fixture.id);
+
+ expect(events.length).toBeGreaterThan(0);
+ expect(events.every((event) => event.bytesTotal === fixture.bytes)).toBe(true);
+ expect(events.at(-1)?.phase).toBe('complete');
+ expect(events.at(-1)?.bytesReceived).toBe(fixture.bytes);
+ });
+});
diff --git a/src/__tests__/main/acappella/models/model-store.test.ts b/src/__tests__/main/acappella/models/model-store.test.ts
new file mode 100644
index 0000000000..7613ad1fef
--- /dev/null
+++ b/src/__tests__/main/acappella/models/model-store.test.ts
@@ -0,0 +1,356 @@
+/**
+ * @file model-store.test.ts
+ *
+ * The store's job is to be pessimistic about disk. These tests pin the four
+ * properties the rest of the subsystem trusts:
+ *
+ * - a manifest round-trips,
+ * - `isInstalled` REJECTS a truncated file (the whole reason it is not an
+ * `existsSync`),
+ * - concurrent manifest writers never produce a half-written file,
+ * - `remove` reclaims the entire directory, partials included.
+ *
+ * Runs against a real temp directory: the failure mode being tested is a
+ * property of the filesystem, so mocking `fs` would test the mock.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+const tempRoot = { dir: '' };
+
+vi.mock('electron', () => ({
+ app: { getPath: () => tempRoot.dir },
+}));
+
+/**
+ * A synthetic catalog entry small enough to actually satisfy.
+ *
+ * The real catalog's hashes belong to files of 2 MB to 1 GB, so the SUCCESS path
+ * of `verify()` cannot be exercised against them without a download. Appending
+ * one tiny entry (real bytes, real hash, computed here) is what makes "verify
+ * passes and stamps verifiedAt" testable at all. Every other test still runs
+ * against the genuine catalog entries.
+ */
+const fixture = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { createHash } = require('crypto') as typeof import('crypto');
+ const contents = Buffer.from('a cappella test model payload');
+ return {
+ contents,
+ id: 'test-tiny-model',
+ sha256: createHash('sha256').update(contents).digest('hex'),
+ bytes: contents.length,
+ };
+});
+
+vi.mock('../../../../shared/acappella/model-catalog', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ const testEntry = Object.freeze({
+ id: fixture.id,
+ displayName: 'Test Tiny Model',
+ role: 'stt' as const,
+ repo: 'maestro/test',
+ revision: '0000000000000000000000000000000000000000',
+ license: 'MIT',
+ licenseUrl: 'https://example.invalid/license',
+ requiredFor: 'local-speech-to-text' as const,
+ description: 'Fixture entry, present only under test.',
+ files: Object.freeze([
+ {
+ path: 'tiny.bin',
+ sourceUrl: 'https://example.invalid/tiny.bin',
+ sha256: fixture.sha256,
+ bytes: fixture.bytes,
+ },
+ ]),
+ bytes: fixture.bytes,
+ });
+ const catalog = Object.freeze([...actual.VOICE_MODEL_CATALOG, testEntry]);
+ const byId = new Map(catalog.map((entry) => [entry.id, entry]));
+ return {
+ ...actual,
+ VOICE_MODEL_CATALOG: catalog,
+ getVoiceModel: (id: string) => byId.get(id),
+ isVoiceModelId: (id: string) => byId.has(id),
+ };
+});
+
+import {
+ buildManifest,
+ getStatus,
+ isInstalled,
+ markInstalled,
+ modelDigest,
+ modelDir,
+ modelFilePath,
+ readManifest,
+ remove,
+ removeAll,
+ totalFootprint,
+ verify,
+ writeManifest,
+} from '../../../../main/acappella/models/model-store';
+import {
+ OPENWAKEWORD_BASE_ID,
+ WHISPER_BASE_EN_ID,
+ getVoiceModel,
+} from '../../../../shared/acappella/model-catalog';
+
+const whisper = getVoiceModel(WHISPER_BASE_EN_ID)!;
+const wakeWord = getVoiceModel(OPENWAKEWORD_BASE_ID)!;
+
+/**
+ * Write files matching the catalog's declared lengths.
+ *
+ * The bytes are zeros, so the LENGTH is right and the HASH is not. That is
+ * deliberate: it is exactly the state a truncated-then-padded file would be in,
+ * and it lets the length check and the hash check be tested independently.
+ */
+async function writeFilesOfDeclaredLength(id: string, entry = getVoiceModel(id)!): Promise {
+ for (const file of entry.files) {
+ const full = modelFilePath(id, file.path);
+ await fs.mkdir(path.dirname(full), { recursive: true });
+ await fs.writeFile(full, Buffer.alloc(file.bytes));
+ }
+}
+
+/** Same, but with a real payload whose hash we control. */
+async function writeFileWithContents(
+ id: string,
+ filePath: string,
+ contents: Buffer
+): Promise {
+ const full = modelFilePath(id, filePath);
+ await fs.mkdir(path.dirname(full), { recursive: true });
+ await fs.writeFile(full, contents);
+}
+
+describe('model-store', () => {
+ let previousUserData: string | undefined;
+
+ beforeEach(async () => {
+ tempRoot.dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-models-'));
+ // `dataDir()` checks MAESTRO_USER_DATA BEFORE app.getPath, and a Maestro-run
+ // agent has that variable set to the real user-data directory. Without this
+ // override the electron mock is bypassed entirely and these tests write to
+ // (and delete from) the live install.
+ previousUserData = process.env.MAESTRO_USER_DATA;
+ process.env.MAESTRO_USER_DATA = tempRoot.dir;
+ });
+
+ afterEach(async () => {
+ if (previousUserData === undefined) delete process.env.MAESTRO_USER_DATA;
+ else process.env.MAESTRO_USER_DATA = previousUserData;
+ await fs.rm(tempRoot.dir, { recursive: true, force: true });
+ });
+
+ describe('manifest round-trip', () => {
+ it('writes and reads back every recorded field', async () => {
+ const manifest = buildManifest(wakeWord, 1_700_000_000_000);
+ await writeManifest(manifest);
+
+ const read = await readManifest(wakeWord.id);
+ expect(read).toEqual(manifest);
+ expect(read?.revision).toBe(wakeWord.revision);
+ expect(read?.license).toBe(wakeWord.license);
+ expect(read?.installedAt).toBe(1_700_000_000_000);
+ expect(read?.verifiedAt).toBe(1_700_000_000_000);
+ expect(read?.files).toHaveLength(wakeWord.files.length);
+ });
+
+ it('reads an absent manifest as null rather than throwing', async () => {
+ expect(await readManifest(whisper.id)).toBeNull();
+ });
+
+ it('reads an unparseable manifest as null, not as an install', async () => {
+ await fs.mkdir(modelDir(whisper.id), { recursive: true });
+ await fs.writeFile(path.join(modelDir(whisper.id), 'manifest.json'), '{ truncated');
+
+ expect(await readManifest(whisper.id)).toBeNull();
+ expect(await isInstalled(whisper.id)).toBe(false);
+ });
+ });
+
+ describe('isInstalled', () => {
+ it('rejects a truncated file even though the file exists', async () => {
+ await markInstalled(wakeWord);
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ expect(await isInstalled(wakeWord.id)).toBe(true);
+
+ // Chop one byte off. `existsSync` would still say yes; this must not.
+ const victim = modelFilePath(wakeWord.id, wakeWord.files[0].path);
+ await fs.truncate(victim, wakeWord.files[0].bytes - 1);
+
+ expect(await isInstalled(wakeWord.id)).toBe(false);
+ const status = await getStatus(wakeWord.id);
+ expect(status.status).toBe('corrupt');
+ expect(status.detail).toContain(wakeWord.files[0].path);
+ });
+
+ it('rejects a manifest with no files on disk', async () => {
+ await markInstalled(wakeWord);
+ expect(await isInstalled(wakeWord.id)).toBe(false);
+ expect((await getStatus(wakeWord.id)).detail).toContain('Missing file');
+ });
+
+ it('rejects files on disk with no manifest', async () => {
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ const status = await getStatus(wakeWord.id);
+ expect(status.status).toBe('not-installed');
+ expect(status.detail).toContain('no manifest');
+ });
+
+ it('reports an install from a superseded catalog revision as not installed', async () => {
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ await writeManifest({
+ ...buildManifest(wakeWord, Date.now()),
+ revision: 'deadbeef',
+ sha256: modelDigest([{ path: 'stale', sha256: 'stale' }]),
+ });
+
+ const status = await getStatus(wakeWord.id);
+ expect(status.status).toBe('not-installed');
+ expect(status.detail).toContain('no longer matches the catalog');
+ });
+ });
+
+ describe('atomic manifest writes under concurrent callers', () => {
+ it('never leaves a partially written manifest', async () => {
+ const writes = Array.from({ length: 25 }, (_, index) =>
+ writeManifest({ ...buildManifest(wakeWord, 1_000 + index), verifiedAt: 2_000 + index })
+ );
+ await Promise.all(writes);
+
+ // Every interleaving has to produce ONE whole manifest, not a concatenated
+ // or truncated one. Parsing it is the assertion.
+ const read = await readManifest(wakeWord.id);
+ expect(read).not.toBeNull();
+ expect(read?.id).toBe(wakeWord.id);
+ expect(read?.files).toHaveLength(wakeWord.files.length);
+
+ // And no temp file survives the race.
+ const entries = await fs.readdir(modelDir(wakeWord.id));
+ expect(entries.filter((name) => name.endsWith('.tmp'))).toHaveLength(0);
+ });
+ });
+
+ describe('verify', () => {
+ it('reports a hash mismatch as corrupt and repairs nothing', async () => {
+ await markInstalled(wakeWord);
+ await writeFilesOfDeclaredLength(wakeWord.id);
+
+ const result = await verify(wakeWord.id);
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe('corrupt');
+ expect(result.mismatch?.expected).toBe(wakeWord.files[0].sha256);
+ expect(result.mismatch?.actual).not.toBe(wakeWord.files[0].sha256);
+
+ // The files are still exactly where they were: no silent delete, no
+ // silent re-download.
+ const size = (await fs.stat(modelFilePath(wakeWord.id, wakeWord.files[0].path))).size;
+ expect(size).toBe(wakeWord.files[0].bytes);
+ });
+
+ it('stamps verifiedAt when every hash matches', async () => {
+ const tiny = getVoiceModel(fixture.id)!;
+ await writeFileWithContents(tiny.id, tiny.files[0].path, fixture.contents);
+ const installed = await markInstalled(tiny);
+
+ const result = await verify(tiny.id);
+ expect(result.ok).toBe(true);
+ expect(result.status).toBe('installed');
+ expect(result.verifiedAt).toBeGreaterThanOrEqual(installed.installedAt);
+
+ const manifest = await readManifest(tiny.id);
+ expect(manifest?.verifiedAt).toBe(result.verifiedAt);
+ expect(manifest?.installedAt).toBe(installed.installedAt);
+ });
+
+ it('reports an uninstalled model without hashing anything', async () => {
+ const result = await verify(whisper.id);
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe('not-installed');
+ });
+ });
+
+ describe('remove and footprint', () => {
+ it('reclaims the whole directory including stray partials', async () => {
+ await markInstalled(wakeWord);
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ await fs.writeFile(
+ `${modelFilePath(wakeWord.id, wakeWord.files[0].path)}.part`,
+ Buffer.alloc(4096)
+ );
+
+ const before = await totalFootprint();
+ expect(before.bytes).toBeGreaterThan(wakeWord.bytes);
+
+ const reclaimed = await remove(wakeWord.id);
+ expect(reclaimed).toBe(before.bytes);
+ await expect(fs.stat(modelDir(wakeWord.id))).rejects.toThrow();
+ expect((await totalFootprint()).bytes).toBe(0);
+ });
+
+ it('counts directories that are no longer in the catalog', async () => {
+ const orphan = path.join(tempRoot.dir, 'models', 'acappella', 'retired-model');
+ await fs.mkdir(orphan, { recursive: true });
+ await fs.writeFile(path.join(orphan, 'weights.bin'), Buffer.alloc(2048));
+
+ const footprint = await totalFootprint();
+ expect(footprint.bytes).toBe(2048);
+ expect(footprint.models.map((model) => model.id)).toContain('retired-model');
+ });
+
+ it('removeAll leaves nothing behind: installed, half-downloaded, or orphaned', async () => {
+ // The reclaim-disk promise in Settings, in one test. A user who switches
+ // the Encore Feature off and accepts the offer is told a number of bytes;
+ // anything this misses is disk they were told they got back and did not.
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ await writeFilesOfDeclaredLength(whisper.id);
+ // A download that was paused or interrupted. The bytes are real and they
+ // are not inside any manifest.
+ await fs.writeFile(
+ `${modelFilePath(whisper.id, whisper.files[0].path)}.part`,
+ Buffer.alloc(4096)
+ );
+ // A directory left by a model the catalog has since dropped. Disk the user
+ // cannot see is disk they cannot get back.
+ const orphan = path.join(tempRoot.dir, 'models', 'acappella', 'retired-model');
+ await fs.mkdir(orphan, { recursive: true });
+ await fs.writeFile(path.join(orphan, 'weights.bin'), Buffer.alloc(2048));
+
+ const expected = (await totalFootprint()).bytes;
+ expect(expected).toBe(wakeWord.bytes + whisper.bytes + 4096 + 2048);
+
+ const reclaimed = await removeAll();
+
+ expect(reclaimed).toBe(expected);
+ expect(await totalFootprint()).toEqual({ bytes: 0, models: [] });
+ });
+
+ it('removeAll deletes only the A Cappella root', async () => {
+ await writeFilesOfDeclaredLength(wakeWord.id);
+ const neighbour = path.join(tempRoot.dir, 'plugins');
+ await fs.mkdir(neighbour, { recursive: true });
+ await fs.writeFile(path.join(neighbour, 'keep.json'), '{}');
+
+ const reclaimed = await removeAll();
+ expect(reclaimed).toBe(wakeWord.bytes);
+ await expect(fs.stat(path.join(neighbour, 'keep.json'))).resolves.toBeTruthy();
+ });
+ });
+
+ describe('path safety', () => {
+ it('refuses an id that is not in the catalog', () => {
+ expect(() => modelDir('../../etc')).toThrow(/UnknownVoiceModel/);
+ });
+
+ it('refuses a file path that escapes the model directory', () => {
+ expect(() => modelFilePath(wakeWord.id, '../../escape.bin')).toThrow(/UnsafeModelFilePath/);
+ });
+ });
+});
diff --git a/src/__tests__/main/acappella/pairing-service.test.ts b/src/__tests__/main/acappella/pairing-service.test.ts
new file mode 100644
index 0000000000..95aef8b924
--- /dev/null
+++ b/src/__tests__/main/acappella/pairing-service.test.ts
@@ -0,0 +1,328 @@
+/**
+ * Device pairing.
+ *
+ * The security model is the thing under test, not the happy path:
+ *
+ * - a code expires, and it is consumed by the FIRST claim whatever happens
+ * next, so a denied request cannot leave a live code behind;
+ * - a code alone pairs nothing - a human on the desktop has to approve;
+ * - the long-lived token is never written to disk in plain text;
+ * - revocation fires immediately, before the write completes, because the one
+ * situation anybody ever uses it in is a device that is connected right now.
+ *
+ * The clock is injected and the file is a temp directory, so nothing here sleeps
+ * and nothing here touches the real user data path.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import { readFileSync } from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+import {
+ DEFAULT_PAIRING_TTL_MS,
+ PairingService,
+ constantTimeEquals,
+ generatePairingCode,
+ hashToken,
+} from '../../../main/acappella/pairing/pairing-service';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let dir: string;
+let filePath: string;
+let now = 1_000_000;
+
+function createService(overrides: { hostSecret?: string } = {}): PairingService {
+ return new PairingService({
+ filePath,
+ hostSecret: overrides.hostSecret ?? 'server-token',
+ now: () => now,
+ });
+}
+
+/** Walk a device all the way to a token: claim, approve, redeem. */
+async function pairDevice(
+ service: PairingService,
+ name = 'Test iPhone'
+): Promise<{ deviceId: string; token: string }> {
+ const offer = service.startPairing();
+ const claim = service.claim({ code: offer.code, name, platform: 'ios' });
+ if (claim.status !== 'pending') throw new Error(`claim failed: ${claim.reason}`);
+ await service.approve(claim.requestId);
+ const redeemed = service.redeem(claim.requestId);
+ if (redeemed.status !== 'approved') throw new Error(`redeem failed: ${redeemed.status}`);
+ return { deviceId: redeemed.deviceId, token: redeemed.token };
+}
+
+beforeEach(async () => {
+ now = 1_000_000;
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-pairing-'));
+ filePath = path.join(dir, 'devices.json');
+});
+
+afterEach(async () => {
+ await fs.rm(dir, { recursive: true, force: true });
+});
+
+describe('pairing codes', () => {
+ it('draws codes from an alphabet with no lookalike glyphs', () => {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ expect(generatePairingCode()).toMatch(/^[2-9BCDFGHJKMNPQRSTVWXYZ]{6}$/);
+ }
+ });
+
+ it('expires the window without anyone touching it', () => {
+ const service = createService();
+ service.startPairing();
+ expect(service.currentOffer()).not.toBeNull();
+ now += DEFAULT_PAIRING_TTL_MS + 1;
+ expect(service.currentOffer()).toBeNull();
+ });
+
+ it('refuses a claim against an expired code', () => {
+ const service = createService();
+ const offer = service.startPairing();
+ now += DEFAULT_PAIRING_TTL_MS + 1;
+ expect(service.claim({ code: offer.code, name: 'Late', platform: 'ios' })).toEqual({
+ status: 'rejected',
+ reason: 'expired',
+ });
+ });
+
+ it('refuses a wrong code', () => {
+ const service = createService();
+ service.startPairing();
+ expect(service.claim({ code: 'ZZZZZZ', name: 'Guess', platform: 'ios' })).toMatchObject({
+ status: 'rejected',
+ reason: 'unknown-code',
+ });
+ });
+
+ it('consumes the code on the first claim, even when the human denies it', () => {
+ const service = createService();
+ const offer = service.startPairing();
+ const first = service.claim({ code: offer.code, name: 'First', platform: 'ios' });
+ expect(first.status).toBe('pending');
+ if (first.status !== 'pending') return;
+
+ service.deny(first.requestId);
+ // The shoulder-surfer's turn. A denied request must not leave a live code.
+ expect(service.claim({ code: offer.code, name: 'Second', platform: 'ios' })).toMatchObject({
+ status: 'rejected',
+ });
+ });
+
+ it('shows the same fingerprint for the same host secret and a different one otherwise', () => {
+ expect(createService({ hostSecret: 'a' }).fingerprint()).toBe(
+ createService({ hostSecret: 'a' }).fingerprint()
+ );
+ expect(createService({ hostSecret: 'a' }).fingerprint()).not.toBe(
+ createService({ hostSecret: 'b' }).fingerprint()
+ );
+ });
+});
+
+describe('desktop approval', () => {
+ it('hands out nothing until a human approves', async () => {
+ const service = createService();
+ const offer = service.startPairing();
+ const claim = service.claim({ code: offer.code, name: 'iPhone', platform: 'ios' });
+ expect(claim.status).toBe('pending');
+ if (claim.status !== 'pending') return;
+
+ // The whole point: the code is known and the device still cannot connect.
+ expect(service.redeem(claim.requestId)).toEqual({ status: 'pending' });
+ expect(await service.list()).toHaveLength(0);
+
+ await service.approve(claim.requestId);
+ const redeemed = service.redeem(claim.requestId);
+ expect(redeemed.status).toBe('approved');
+ });
+
+ it('reports the waiting request so the desktop can render it', () => {
+ const service = createService();
+ const seen: Array = [];
+ service.onPairingRequest((request) => seen.push(request?.name ?? null));
+ const offer = service.startPairing();
+ service.claim({ code: offer.code, name: 'Pedram iPhone', platform: 'ios' });
+ expect(service.pendingRequest()?.name).toBe('Pedram iPhone');
+ expect(seen).toContain('Pedram iPhone');
+ });
+
+ it('returns nothing at all on a denial', () => {
+ const service = createService();
+ const offer = service.startPairing();
+ const claim = service.claim({ code: offer.code, name: 'iPhone', platform: 'ios' });
+ if (claim.status !== 'pending') throw new Error('claim failed');
+ service.deny(claim.requestId);
+ expect(service.redeem(claim.requestId)).toEqual({ status: 'denied' });
+ });
+
+ it('redeems exactly once', async () => {
+ const service = createService();
+ const offer = service.startPairing();
+ const claim = service.claim({ code: offer.code, name: 'iPhone', platform: 'ios' });
+ if (claim.status !== 'pending') throw new Error('claim failed');
+ await service.approve(claim.requestId);
+
+ expect(service.redeem(claim.requestId).status).toBe('approved');
+ // A replayed redemption gets nothing: the token is deleted as it is handed
+ // over, so it exists in memory for one call and no longer.
+ expect(service.redeem(claim.requestId).status).toBe('expired');
+ });
+
+ it('lets the desktop rename the device at approval time', async () => {
+ const service = createService();
+ const offer = service.startPairing();
+ const claim = service.claim({ code: offer.code, name: 'iPhone', platform: 'ios' });
+ if (claim.status !== 'pending') throw new Error('claim failed');
+ const device = await service.approve(claim.requestId, 'Walking phone');
+ expect(device?.name).toBe('Walking phone');
+ });
+});
+
+describe('token storage', () => {
+ it('never writes the token in plain text', async () => {
+ const service = createService();
+ const { token } = await pairDevice(service);
+
+ const raw = await fs.readFile(filePath, 'utf-8');
+ expect(raw).not.toContain(token);
+ const parsed = JSON.parse(raw) as { devices: Array> };
+ expect(parsed.devices[0].tokenHash).toEqual(expect.any(String));
+ expect(parsed.devices[0].tokenHash).not.toBe(token);
+ });
+
+ it('salts per device, so two devices with the same token would not collide', async () => {
+ const service = createService();
+ await pairDevice(service, 'One');
+ await pairDevice(service, 'Two');
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as {
+ devices: Array<{ tokenSalt: string }>;
+ };
+ expect(parsed.devices[0].tokenSalt).not.toBe(parsed.devices[1].tokenSalt);
+ expect(hashToken('same', 'a')).not.toBe(hashToken('same', 'b'));
+ });
+
+ it('authenticates a returning device from the stored hash alone', async () => {
+ const service = createService();
+ const { deviceId, token } = await pairDevice(service);
+
+ // A fresh service, as after a restart: nothing in memory, only the file.
+ const restarted = createService();
+ expect(await restarted.authenticate(deviceId, token)).toMatchObject({ id: deviceId });
+ });
+
+ it('refuses a wrong token and an unknown device the same way', async () => {
+ const service = createService();
+ const { deviceId } = await pairDevice(service);
+ expect(await service.authenticate(deviceId, 'wrong')).toBeNull();
+ expect(await service.authenticate('no-such-device', 'wrong')).toBeNull();
+ });
+
+ it('compares in constant time without throwing on a length mismatch', () => {
+ expect(constantTimeEquals('abc', 'abc')).toBe(true);
+ expect(constantTimeEquals('abc', 'abcdefghij')).toBe(false);
+ });
+
+ it('survives a corrupt device file rather than refusing to run', async () => {
+ await fs.writeFile(filePath, 'not json at all', 'utf-8');
+ const service = createService();
+ expect(await service.list()).toEqual([]);
+ });
+});
+
+describe('revocation', () => {
+ it('fires before the write lands, so a live peer is not held up by a disk flush', async () => {
+ const service = createService();
+ const { deviceId, token } = await pairDevice(service);
+
+ const torn: string[] = [];
+ /** What was on disk at the moment the teardown listener ran. */
+ let diskAtTeardown = '';
+ service.onRevoke((id) => {
+ torn.push(id);
+ diskAtTeardown = readFileSync(filePath, 'utf-8');
+ });
+
+ await service.revoke(deviceId);
+
+ expect(torn).toEqual([deviceId]);
+ // The file still said the device was live when the peer was torn down: the
+ // urgent half does not wait on a disk flush. A revocation that did would
+ // leave a revoked phone holding the microphone while a slow disk caught up.
+ expect(JSON.parse(diskAtTeardown).devices[0].revokedAt).toBeNull();
+ expect(await service.authenticate(deviceId, token)).toBeNull();
+ });
+
+ it('refuses the revoked device on its next attempt', async () => {
+ const service = createService();
+ const { deviceId, token } = await pairDevice(service);
+ expect(await service.authenticate(deviceId, token)).not.toBeNull();
+
+ await service.revoke(deviceId);
+ expect(await service.authenticate(deviceId, token)).toBeNull();
+
+ // And after a restart, because the revocation is persisted rather than held
+ // in the memory of the process that performed it.
+ expect(await createService().authenticate(deviceId, token)).toBeNull();
+ });
+
+ it('is idempotent', async () => {
+ const service = createService();
+ const { deviceId } = await pairDevice(service);
+ expect(await service.revoke(deviceId)).toBe(true);
+ expect(await service.revoke(deviceId)).toBe(false);
+ });
+
+ it('revokes everything at once and reports how many', async () => {
+ const service = createService();
+ await pairDevice(service, 'One');
+ await pairDevice(service, 'Two');
+ const torn: string[] = [];
+ service.onRevoke((id) => torn.push(id));
+
+ expect(await service.revokeAll()).toBe(2);
+ expect(torn).toHaveLength(2);
+ expect(await service.revokeAll()).toBe(0);
+ });
+
+ it('keeps a revoked device visible until it is explicitly forgotten', async () => {
+ const service = createService();
+ const { deviceId } = await pairDevice(service);
+ await service.revoke(deviceId);
+ expect(await service.list()).toHaveLength(1);
+ expect(await service.forget(deviceId)).toBe(true);
+ expect(await service.list()).toHaveLength(0);
+ });
+});
+
+describe('device list', () => {
+ it('records how a device last connected', async () => {
+ const service = createService();
+ const { deviceId } = await pairDevice(service);
+ await service.noteConnected(deviceId, 'relay');
+ const [device] = await service.list();
+ expect(device.lastCandidateType).toBe('relay');
+ expect(device.lastConnectedAt).toBe(now);
+ });
+
+ it('renames', async () => {
+ const service = createService();
+ const { deviceId } = await pairDevice(service);
+ expect(await service.rename(deviceId, 'Bedroom phone')).toBe(true);
+ expect((await service.list())[0].name).toBe('Bedroom phone');
+ });
+
+ it('never exposes credential material to a caller', async () => {
+ const service = createService();
+ await pairDevice(service);
+ const [device] = await service.list();
+ expect(device).not.toHaveProperty('tokenHash');
+ expect(device).not.toHaveProperty('tokenSalt');
+ });
+});
diff --git a/src/__tests__/main/acappella/permissions/mic-permission.test.ts b/src/__tests__/main/acappella/permissions/mic-permission.test.ts
new file mode 100644
index 0000000000..63f0a166a8
--- /dev/null
+++ b/src/__tests__/main/acappella/permissions/mic-permission.test.ts
@@ -0,0 +1,309 @@
+/**
+ * @file mic-permission.test.ts
+ *
+ * Two properties, and the second one is the reason this module exists at all:
+ *
+ * 1. Every OS state is distinguished. `not-determined` is not `denied`, and
+ * `restricted` is not `denied` either, because the recovery differs: wait
+ * to be asked, tick a checkbox, or talk to whoever manages the machine.
+ * 2. A microphone problem and a model problem produce DIFFERENT gate reasons.
+ * Conflating them is what turns a one-checkbox fix into a support ticket,
+ * so the gate is tested here alongside the permission itself.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+
+const getMediaAccessStatus = vi.fn();
+const askForMediaAccess = vi.fn();
+const openExternal = vi.fn();
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/acappella-mic-permission-test' },
+ shell: { openExternal: (url: string) => openExternal(url) },
+ systemPreferences: {
+ getMediaAccessStatus: (type: string) => getMediaAccessStatus(type),
+ askForMediaAccess: (type: string) => askForMediaAccess(type),
+ },
+}));
+
+import {
+ getMicPermission,
+ noteCaptureFailure,
+ noteCaptureStarted,
+ openMicSystemSettings,
+ requestMicPermission,
+ resetMicPermissionObservation,
+} from '../../../../main/acappella/permissions/mic-permission';
+import { resolveVoiceReadiness } from '../../../../main/acappella/models/capability-gate';
+import type { ModelStatus } from '../../../../main/acappella/models/model-store';
+import { WHISPER_BASE_EN_ID } from '../../../../shared/acappella/model-catalog';
+
+const REAL_PLATFORM = process.platform;
+
+function setPlatform(platform: string): void {
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true });
+}
+
+function statusReader(overrides: Record = {}) {
+ return async (modelId: string): Promise => ({
+ id: modelId,
+ status: overrides[modelId] ?? 'installed',
+ manifest: null,
+ bytesOnDisk: 1024,
+ });
+}
+
+describe('mic-permission', () => {
+ beforeEach(() => {
+ resetMicPermissionObservation();
+ getMediaAccessStatus.mockReset();
+ askForMediaAccess.mockReset();
+ openExternal.mockReset();
+ setPlatform('darwin');
+ });
+
+ afterEach(() => {
+ setPlatform(REAL_PLATFORM);
+ resetMicPermissionObservation();
+ });
+
+ describe('every state stays its own state', () => {
+ it.each([
+ ['not-determined', 'not-determined'],
+ ['granted', 'granted'],
+ ['denied', 'denied'],
+ ['restricted', 'restricted'],
+ ])('reports %s as %s', (osStatus, expected) => {
+ getMediaAccessStatus.mockReturnValue(osStatus);
+ expect(getMicPermission().state).toBe(expected);
+ });
+
+ it('offers a prompt only when the OS has one to show', () => {
+ getMediaAccessStatus.mockReturnValue('not-determined');
+ expect(getMicPermission().canPrompt).toBe(true);
+
+ getMediaAccessStatus.mockReturnValue('denied');
+ // Once the user has said no, the app cannot ask again. A "Grant access"
+ // button here would do nothing, which is worse than no button.
+ expect(getMicPermission().canPrompt).toBe(false);
+ });
+
+ it('never prompts while querying', () => {
+ getMediaAccessStatus.mockReturnValue('not-determined');
+ getMicPermission();
+ // The whole point: readiness is resolved on every Settings render, so a
+ // query that could raise a TCC dialog would ask for the microphone behind
+ // a user who has not asked for voice.
+ expect(askForMediaAccess).not.toHaveBeenCalled();
+ });
+
+ it('falls back to unknown when the OS query throws', () => {
+ getMediaAccessStatus.mockImplementation(() => {
+ throw new Error('not implemented on this platform');
+ });
+ expect(getMicPermission().state).toBe('unknown');
+ });
+ });
+
+ describe('requesting', () => {
+ it('asks once on macOS when the state is undetermined', async () => {
+ getMediaAccessStatus.mockReturnValue('not-determined');
+ askForMediaAccess.mockResolvedValue(true);
+
+ const info = await requestMicPermission();
+
+ expect(askForMediaAccess).toHaveBeenCalledWith('microphone');
+ expect(info.state).toBe('granted');
+ });
+
+ it('does not re-ask once the user has answered', async () => {
+ getMediaAccessStatus.mockReturnValue('denied');
+
+ const info = await requestMicPermission();
+
+ expect(askForMediaAccess).not.toHaveBeenCalled();
+ expect(info.state).toBe('denied');
+ });
+
+ it('reports the refusal when the user says no', async () => {
+ getMediaAccessStatus.mockReturnValue('not-determined');
+ askForMediaAccess.mockResolvedValue(false);
+
+ expect((await requestMicPermission()).state).toBe('denied');
+ });
+
+ it('does not claim a denial when the API itself throws', async () => {
+ getMediaAccessStatus.mockReturnValue('not-determined');
+ askForMediaAccess.mockRejectedValue(new Error('unavailable'));
+
+ // Reporting "denied" here would send the user to fix a checkbox that is
+ // already correct.
+ expect((await requestMicPermission()).state).toBe('not-determined');
+ });
+
+ it('never pretends to prompt on Windows', async () => {
+ setPlatform('win32');
+ getMediaAccessStatus.mockReturnValue('denied');
+
+ const info = await requestMicPermission();
+
+ expect(askForMediaAccess).not.toHaveBeenCalled();
+ expect(info.state).toBe('denied');
+ expect(info.canPrompt).toBe(false);
+ });
+ });
+
+ describe('the getUserMedia failure path', () => {
+ it('is the only permission signal on Linux', () => {
+ setPlatform('linux');
+ expect(getMicPermission().state).toBe('unknown');
+
+ noteCaptureFailure('permission-denied');
+
+ expect(getMicPermission().state).toBe('denied');
+ // Linux has no privacy-pane deep link that works across desktops, so the
+ // UI has to offer words rather than a button.
+ expect(getMicPermission().settingsUrl).toBeNull();
+ });
+
+ it('does not mistake a missing device for a denial', () => {
+ setPlatform('linux');
+ noteCaptureFailure('no-device');
+ noteCaptureFailure('device-lost');
+ expect(getMicPermission().state).toBe('unknown');
+ });
+
+ it('does not outrank an OS that has an answer', () => {
+ setPlatform('win32');
+ getMediaAccessStatus.mockReturnValue('granted');
+ noteCaptureFailure('permission-denied');
+
+ // The OS wins because it is what will actually decide the next capture,
+ // and it updates the instant the user changes the setting. A remembered
+ // denial that outranked it would keep blocking sessions after the user
+ // had already fixed the problem.
+ expect(getMicPermission().state).toBe('granted');
+ });
+
+ it('fills the gap when the OS query is unavailable', () => {
+ setPlatform('win32');
+ getMediaAccessStatus.mockImplementation(() => {
+ throw new Error('not implemented');
+ });
+ noteCaptureFailure('permission-denied');
+
+ expect(getMicPermission().state).toBe('denied');
+ });
+
+ it('clears once capture actually starts, which is the only proof of a grant', () => {
+ setPlatform('linux');
+ noteCaptureFailure('permission-denied');
+ expect(getMicPermission().state).toBe('denied');
+
+ noteCaptureStarted();
+ expect(getMicPermission().state).toBe('unknown');
+ });
+
+ it('does not survive the next session start, so one denial cannot deadlock voice', async () => {
+ setPlatform('linux');
+ noteCaptureFailure('permission-denied');
+ expect(getMicPermission().state).toBe('denied');
+
+ // The user granted access and pressed the button again. Without this, the
+ // capability gate would block the session forever on the strength of one
+ // old failure, and the successful capture that would clear it is exactly
+ // what the gate is refusing to allow.
+ await requestMicPermission();
+
+ expect(getMicPermission().state).toBe('unknown');
+ });
+ });
+
+ describe('opening system settings', () => {
+ it('opens the privacy pane where one exists', async () => {
+ expect(await openMicSystemSettings()).toBe(true);
+ expect(openExternal).toHaveBeenCalledWith(
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
+ );
+ });
+
+ it('reports false rather than opening nothing on Linux', async () => {
+ setPlatform('linux');
+ expect(await openMicSystemSettings()).toBe(false);
+ expect(openExternal).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('the capability gate keeps permission and models apart', () => {
+ it('blocks on a denied microphone with its own reason and recovery', async () => {
+ const readiness = await resolveVoiceReadiness({
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'denied',
+ });
+
+ const mic = readiness.slots.find((slot) => slot.slot === 'microphone')!;
+ expect(mic.satisfied).toBe(false);
+ expect(mic.reason).toBe('mic-permission-denied');
+ expect(mic.detail).toContain('microphone access');
+ expect(mic.suggestedAction).toContain('privacy settings');
+ expect(readiness.canStartSession).toBe(false);
+ });
+
+ it('distinguishes a restricted microphone, which the user cannot fix', async () => {
+ const readiness = await resolveVoiceReadiness({
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'restricted',
+ });
+
+ const mic = readiness.slots.find((slot) => slot.slot === 'microphone')!;
+ expect(mic.reason).toBe('mic-permission-restricted');
+ // No privacy-pane instruction: sending someone to a checkbox they are not
+ // allowed to tick is a dead end.
+ expect(mic.suggestedAction).not.toContain('privacy settings');
+ });
+
+ it.each(['not-determined', 'unknown', 'granted'] as const)(
+ 'does not block on %s, because nothing has been refused',
+ async (permission) => {
+ const readiness = await resolveVoiceReadiness({
+ readModelStatus: statusReader(),
+ readMicPermission: () => permission,
+ });
+
+ expect(readiness.slots.find((slot) => slot.slot === 'microphone')?.satisfied).toBe(true);
+ expect(readiness.canStartSession).toBe(true);
+ }
+ );
+
+ it('gives a missing model and a denied microphone two different reasons at once', async () => {
+ const readiness = await resolveVoiceReadiness({
+ settings: { stt: 'whisper-local' },
+ readModelStatus: statusReader({ [WHISPER_BASE_EN_ID]: 'not-installed' }),
+ readMicPermission: () => 'denied',
+ // This test is about permission and model being separate answers, so
+ // the third dimension is held out of it: against the real registry the
+ // local runtime is not in the build yet, and the gate reports that
+ // ahead of a download it would not fix.
+ readRuntimeFailure: () => null,
+ });
+
+ // The failure this whole module exists to prevent: one generic "voice
+ // unavailable" covering two unrelated problems with two unrelated fixes.
+ const reasons = readiness.blocking.map((slot) => slot.reason);
+ expect(reasons).toContain('mic-permission-denied');
+ expect(reasons).toContain('model-not-installed');
+ expect(new Set(reasons).size).toBe(2);
+ });
+
+ it('reports the microphone permission on the slot even when satisfied', async () => {
+ const readiness = await resolveVoiceReadiness({
+ readModelStatus: statusReader(),
+ readMicPermission: () => 'not-determined',
+ });
+
+ expect(readiness.slots.find((slot) => slot.slot === 'microphone')?.micPermission).toBe(
+ 'not-determined'
+ );
+ });
+ });
+});
diff --git a/src/__tests__/main/acappella/providers/brain-prompt.test.ts b/src/__tests__/main/acappella/providers/brain-prompt.test.ts
new file mode 100644
index 0000000000..82ec0063c4
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/brain-prompt.test.ts
@@ -0,0 +1,199 @@
+/**
+ * @file brain-prompt.test.ts
+ *
+ * The parser every Brain shares. It exists because a model asked for JSON will
+ * eventually return a fenced block, a preamble, an agent id that closed while it
+ * was thinking, or a confidence of 7 - and none of those may become a dispatch.
+ * Sending a spoken instruction to the wrong agent is the worst thing this feature
+ * can do, and it is worse than doing nothing.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ buildRouteUserPrompt,
+ extractJsonObject,
+ limitSpokenReply,
+ parseRouteDecision,
+} from '../../../../main/acappella/providers/brain-prompt';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+import type { VoiceRouteContext } from '../../../../shared/acappella/providers';
+
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [
+ { id: 'tab-auth', name: 'Auth', lastActiveAt: 1 },
+ { id: 'tab-db', name: 'Migrations', lastActiveAt: 2 },
+ ],
+ },
+];
+
+const CONTEXT: VoiceRouteContext = { roster: ROSTER, scope: { kind: 'conductor' } };
+
+describe('extractJsonObject', () => {
+ it('finds an object inside a fenced block with a preamble', () => {
+ const raw = 'Sure! Here you go:\n```json\n{"target":"conductor"}\n```';
+ expect(extractJsonObject(raw)).toEqual({ target: 'conductor' });
+ });
+
+ it('keeps braces that belong to a string value', () => {
+ // The non-greedy regex that "worked" would truncate here.
+ const raw = '{"prompt":"replace {old} with {new}","confidence":0.5}';
+ expect(extractJsonObject(raw)).toMatchObject({ prompt: 'replace {old} with {new}' });
+ });
+
+ it('returns null for text with no object in it', () => {
+ expect(extractJsonObject('I think you want the backend agent.')).toBeNull();
+ });
+
+ it('returns null rather than throwing on malformed JSON', () => {
+ expect(extractJsonObject('{"target": }')).toBeNull();
+ });
+});
+
+describe('parseRouteDecision', () => {
+ it('accepts a well-formed decision for a running agent', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ tabId: 'tab-auth',
+ prompt: 'what happened to auth',
+ confidence: 0.82,
+ }),
+ CONTEXT,
+ 'fallback'
+ );
+
+ expect(decision).toEqual({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ tabId: 'tab-auth',
+ tabName: undefined,
+ prompt: 'what happened to auth',
+ confidence: 0.82,
+ });
+ });
+
+ it('sends an unknown agent id to the conductor', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({ target: { sessionId: 'ghost' }, tabAction: 'current', prompt: 'x' }),
+ CONTEXT,
+ 'fallback'
+ );
+
+ expect(decision.target).toBe('conductor');
+ });
+
+ it('downgrades a recall of a tab that does not exist', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ tabId: 'tab-that-closed',
+ prompt: 'x',
+ }),
+ CONTEXT,
+ 'fallback'
+ );
+
+ // A recall the executor cannot perform fails the turn; the tab the user is
+ // already looking at is the honest downgrade.
+ expect(decision.tabAction).toBe('current');
+ expect(decision.tabId).toBeUndefined();
+ });
+
+ it('drops a tabId on an action that does not take one', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({ target: 'conductor', tabAction: 'new', tabId: 'tab-auth', prompt: 'x' }),
+ CONTEXT,
+ 'fallback'
+ );
+
+ expect(decision.tabId).toBeUndefined();
+ });
+
+ it('falls back to current for an action it has never heard of', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({ target: 'conductor', tabAction: 'teleport', prompt: 'x' }),
+ CONTEXT,
+ 'fallback'
+ );
+
+ expect(decision.tabAction).toBe('current');
+ });
+
+ it('uses the user own words when the model gave no prompt', () => {
+ const decision = parseRouteDecision(
+ JSON.stringify({ target: 'conductor', tabAction: 'current' }),
+ CONTEXT,
+ ' open the auth tab '
+ );
+
+ // A verbatim utterance is a worse prompt than a cleaned one and an
+ // infinitely better outcome than a turn that silently did nothing.
+ expect(decision.prompt).toBe('open the auth tab');
+ });
+
+ it('clamps a confidence outside 0 to 1', () => {
+ const high = parseRouteDecision(
+ JSON.stringify({ target: 'conductor', tabAction: 'current', prompt: 'x', confidence: 7 }),
+ CONTEXT,
+ 'x'
+ );
+ const missing = parseRouteDecision(
+ JSON.stringify({ target: 'conductor', tabAction: 'current', prompt: 'x' }),
+ CONTEXT,
+ 'x'
+ );
+
+ expect(high.confidence).toBe(1);
+ expect(missing.confidence).toBe(0.5);
+ });
+
+ it('survives a response with no JSON at all', () => {
+ const decision = parseRouteDecision('I could not decide.', CONTEXT, 'open the auth tab');
+
+ expect(decision.target).toBe('conductor');
+ expect(decision.prompt).toBe('open the auth tab');
+ });
+});
+
+describe('buildRouteUserPrompt', () => {
+ it('names every running agent and its tabs', () => {
+ const prompt = buildRouteUserPrompt('open auth', CONTEXT);
+
+ expect(prompt).toContain('agent-backend');
+ expect(prompt).toContain('tab-auth');
+ expect(prompt).toContain('Utterance: open auth');
+ });
+
+ it('says which agent the session is bound to', () => {
+ const prompt = buildRouteUserPrompt('do it', {
+ roster: ROSTER,
+ scope: { kind: 'agent', sessionId: 'agent-backend' },
+ });
+
+ expect(prompt).toContain('bound to agent agent-backend');
+ });
+
+ it('says so plainly when nothing is running', () => {
+ expect(buildRouteUserPrompt('hello', { roster: [], scope: { kind: 'conductor' } })).toContain(
+ '(none)'
+ );
+ });
+});
+
+describe('limitSpokenReply', () => {
+ it('strips markdown and trims to the budget', () => {
+ expect(limitSpokenReply('**One.** Two. Three.', 2)).toBe('One. Two.');
+ });
+
+ it('returns nothing for a reply with nothing in it', () => {
+ expect(limitSpokenReply(' ', 2)).toBe('');
+ });
+});
diff --git a/src/__tests__/main/acappella/providers/credentials.test.ts b/src/__tests__/main/acappella/providers/credentials.test.ts
new file mode 100644
index 0000000000..36267620ac
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/credentials.test.ts
@@ -0,0 +1,256 @@
+/**
+ * @file credentials.test.ts
+ *
+ * The credential layer's two promises:
+ *
+ * 1. A key round-trips through the OS keychain and NOWHERE else. The test that
+ * matters here is the negative one: no code path writes a key into the
+ * settings store or into a log line. That is checked by scanning what the
+ * module actually did with a fake store and a fake logger, rather than by
+ * reading the source and trusting it.
+ * 2. Validation tells the three outcomes apart. A rate limit is not a bad key,
+ * and telling a throttled user to paste a new one would have them fix a
+ * problem that fixes itself.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const settingsWrites: Array<[string, unknown]> = [];
+const logLines: string[] = [];
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: {
+ info: (message: string) => logLines.push(message),
+ warn: (message: string) => logLines.push(message),
+ error: (message: string) => logLines.push(message),
+ debug: (message: string) => logLines.push(message),
+ },
+}));
+
+import {
+ __setCredentialEntryFactory,
+ clearCredential,
+ getCredential,
+ hasCredential,
+ listCredentialStates,
+ redactSecrets,
+ redactSecretsDeep,
+ setCredential,
+ validateCredential,
+ REDACTED,
+} from '../../../../main/acappella/providers/credentials';
+import type { KeyringEntry } from '../../../../main/utils/keyring';
+
+const SECRET = 'sk-test-abcdefghijklmnopqrstuvwxyz012345';
+
+/** An in-memory keyring entry, standing in for the OS credential store. */
+function fakeEntry(): KeyringEntry & { value: string | null } {
+ return {
+ value: null,
+ getPassword() {
+ return this.value;
+ },
+ setPassword(password: string) {
+ this.value = password;
+ },
+ deletePassword() {
+ const had = this.value !== null;
+ this.value = null;
+ return had;
+ },
+ };
+}
+
+let entries: Record>;
+
+beforeEach(() => {
+ settingsWrites.length = 0;
+ logLines.length = 0;
+ entries = {};
+ __setCredentialEntryFactory((service) => {
+ entries[service] ??= fakeEntry();
+ return entries[service];
+ });
+});
+
+afterEach(() => {
+ __setCredentialEntryFactory(null);
+});
+
+describe('credential storage', () => {
+ it('round-trips a key through the keychain', () => {
+ expect(setCredential('openai', SECRET)).toEqual({ ok: true });
+ expect(getCredential('openai')).toBe(SECRET);
+ expect(hasCredential('openai')).toBe(true);
+ expect(entries.openai.value).toBe(SECRET);
+ });
+
+ it('keeps services apart', () => {
+ setCredential('openai', SECRET);
+ expect(hasCredential('elevenlabs')).toBe(false);
+ expect(hasCredential('anthropic')).toBe(false);
+ });
+
+ it('trims, and treats a whitespace-only key as a clear', () => {
+ setCredential('anthropic', ` ${SECRET} `);
+ expect(getCredential('anthropic')).toBe(SECRET);
+
+ setCredential('anthropic', ' ');
+ expect(getCredential('anthropic')).toBeNull();
+ });
+
+ it('clears a key', () => {
+ setCredential('elevenlabs', 'abc123');
+ expect(clearCredential('elevenlabs')).toEqual({ ok: true });
+ expect(hasCredential('elevenlabs')).toBe(false);
+ });
+
+ it('reports, rather than crashes, when the machine has no keyring', () => {
+ __setCredentialEntryFactory(() => null);
+
+ const result = setCredential('openai', SECRET);
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('credential store');
+ // The important half: no fallback to a file. A machine without a keychain
+ // simply cannot use the hosted tier.
+ expect(result.error).toContain('never written to disk');
+ expect(hasCredential('openai')).toBe(false);
+ });
+
+ it('survives a locked keychain that throws on read', () => {
+ __setCredentialEntryFactory(() => ({
+ getPassword: () => {
+ throw new Error('keychain is locked');
+ },
+ setPassword: () => {},
+ deletePassword: () => false,
+ }));
+
+ expect(getCredential('openai')).toBeNull();
+ expect(logLines.join('\n')).toContain('keychain is locked');
+ });
+
+ it('lists configured state without ever returning a key', () => {
+ setCredential('openai', SECRET);
+ const states = listCredentialStates();
+
+ expect(states.find((state) => state.service === 'openai')).toMatchObject({
+ configured: true,
+ keyringAvailable: true,
+ });
+ expect(JSON.stringify(states)).not.toContain(SECRET);
+ });
+
+ it('never writes a key into the settings store', () => {
+ setCredential('openai', SECRET);
+ setCredential('elevenlabs', 'el-secret-value');
+ void listCredentialStates();
+ void getCredential('openai');
+
+ // Nothing in this module touches a settings store at all; this asserts the
+ // property rather than the implementation, so a future change that reaches
+ // for one fails here.
+ expect(settingsWrites).toEqual([]);
+ });
+
+ it('never logs a key', () => {
+ setCredential('openai', SECRET);
+ void getCredential('openai');
+ void hasCredential('openai');
+
+ expect(logLines.join('\n')).not.toContain(SECRET);
+ });
+});
+
+describe('credential validation', () => {
+ it('accepts a key the service accepts', async () => {
+ const fetchImpl = vi.fn(async () => new Response('{}', { status: 200 }));
+ const result = await validateCredential('openai', SECRET, fetchImpl);
+
+ expect(result.status).toBe('valid');
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ it('tells a rate limit apart from a bad key', async () => {
+ const limited = await validateCredential(
+ 'openai',
+ SECRET,
+ async () => new Response('', { status: 429 })
+ );
+ expect(limited.status).toBe('rate-limited');
+ expect(limited.message).toContain('looks fine');
+
+ const rejected = await validateCredential(
+ 'openai',
+ SECRET,
+ async () => new Response('', { status: 401 })
+ );
+ expect(rejected.status).toBe('invalid');
+ expect(rejected.message).toContain('revoked');
+ });
+
+ it('reports a server error as a network problem, not a bad key', async () => {
+ const result = await validateCredential(
+ 'elevenlabs',
+ 'el-key-value',
+ async () => new Response('', { status: 503 })
+ );
+ expect(result.status).toBe('network-error');
+ });
+
+ it('reports an unreachable service without quoting the request', async () => {
+ const result = await validateCredential('anthropic', 'sk-ant-abcdefghijklmnop', async () => {
+ throw new Error('getaddrinfo ENOTFOUND api.anthropic.com');
+ });
+
+ expect(result.status).toBe('network-error');
+ expect(result.message).not.toContain('sk-ant-');
+ });
+
+ it('rejects an obviously wrong key shape without a round trip', async () => {
+ const fetchImpl = vi.fn();
+ const result = await validateCredential('openai', 'not-a-key', fetchImpl as never);
+
+ expect(result.status).toBe('invalid');
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('says so when nothing is stored', async () => {
+ const result = await validateCredential('openai', undefined, async () => new Response('{}'));
+ expect(result.status).toBe('missing');
+ });
+
+ it('validates the STORED key when none is passed', async () => {
+ setCredential('openai', SECRET);
+ const fetchImpl = vi.fn(async () => new Response('{}', { status: 200 }));
+
+ expect((await validateCredential('openai', undefined, fetchImpl)).status).toBe('valid');
+ const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
+ expect((init.headers as Record).Authorization).toContain(SECRET);
+ });
+});
+
+describe('redaction', () => {
+ it('scrubs key shapes out of free text', () => {
+ expect(redactSecrets(`Authorization: Bearer ${SECRET}`)).not.toContain(SECRET);
+ expect(redactSecrets(`{"api_key": "abcdef1234567890"}`)).toContain(REDACTED);
+ expect(redactSecrets('xi-api-key: 0123456789abcdef0123')).toContain(REDACTED);
+ });
+
+ it('leaves ordinary text alone', () => {
+ expect(redactSecrets('the session started in 4ms')).toBe('the session started in 4ms');
+ });
+
+ it('scrubs a nested payload by value and by key name', () => {
+ const scrubbed = redactSecretsDeep({
+ provider: 'openai',
+ headers: { authorization: `Bearer ${SECRET}` },
+ nested: [{ apiKey: 'short' }],
+ }) as { headers: { authorization: string }; nested: Array<{ apiKey: string }> };
+
+ expect(scrubbed.headers.authorization).toBe(REDACTED);
+ // A short value that no pattern would match is still redacted, because the
+ // KEY said what it was.
+ expect(scrubbed.nested[0].apiKey).toBe(REDACTED);
+ });
+});
diff --git a/src/__tests__/main/acappella/providers/hosted-providers.test.ts b/src/__tests__/main/acappella/providers/hosted-providers.test.ts
new file mode 100644
index 0000000000..3b153dfba0
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/hosted-providers.test.ts
@@ -0,0 +1,534 @@
+/**
+ * @file hosted-providers.test.ts
+ *
+ * The hosted tier, against a mocked transport. Three properties per backend,
+ * because these are the three that fail silently in production:
+ *
+ * - **Streaming.** Partial transcripts arrive as the deltas do, rather than in
+ * one lump at the end.
+ * - **Cancellation aborts the REQUEST.** A barge-in that only stops iterating
+ * leaves the socket open and the account paying for audio nobody will hear,
+ * so the test asserts the abort signal actually fired.
+ * - **Classification.** Quota, auth, and network come back as distinct kinds
+ * with distinct protocol codes. Collapsing them is what tells a user with an
+ * expired key to go and download a model.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { OpenAiSttProvider } from '../../../../main/acappella/providers/hosted/openai-stt';
+import { ElevenLabsTtsProvider } from '../../../../main/acappella/providers/hosted/elevenlabs-tts';
+import { OpenAiBrainProvider } from '../../../../main/acappella/providers/hosted/openai-brain';
+import { AnthropicBrainProvider } from '../../../../main/acappella/providers/hosted/anthropic-brain';
+import { hostedRequest, MAX_ATTEMPTS } from '../../../../main/acappella/providers/hosted/http';
+import { isVoiceProviderError } from '../../../../shared/acappella/provider-errors';
+import type { SttCallbacks, TtsChunk } from '../../../../shared/acappella/providers';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+
+const KEY = () => 'sk-test-abcdefghijklmnopqrstuvwxyz';
+
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [{ id: 'tab-auth', name: 'Auth', lastActiveAt: 1 }],
+ },
+];
+
+function recorder(): {
+ callbacks: SttCallbacks;
+ partials: string[];
+ finals: string[];
+ errors: Error[];
+} {
+ const partials: string[] = [];
+ const finals: string[] = [];
+ const errors: Error[] = [];
+ return {
+ partials,
+ finals,
+ errors,
+ callbacks: {
+ onPartial: (text) => partials.push(text),
+ onFinal: (text) => finals.push(text),
+ onError: (error) => errors.push(error),
+ },
+ };
+}
+
+/** A `text/event-stream` body built from event objects. */
+function sseResponse(events: unknown[]): Response {
+ const body = new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder();
+ for (const event of events) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ }
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
+ controller.close();
+ },
+ });
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
+}
+
+/** One second of silence, enough to clear the minimum-utterance floor. */
+function utterancePcm(): Int16Array {
+ return new Int16Array(16_000);
+}
+
+// ---------------------------------------------------------------------------
+// Transport
+// ---------------------------------------------------------------------------
+
+describe('hosted transport', () => {
+ const base = {
+ providerId: 'openai-stt',
+ service: 'openai' as const,
+ url: 'https://example.test/v1/thing',
+ timeoutMs: 1_000,
+ delayMs: async () => {},
+ };
+
+ it('retries a 429 and succeeds', async () => {
+ let calls = 0;
+ const response = await hostedRequest({
+ ...base,
+ fetchImpl: async () => {
+ calls += 1;
+ return calls === 1
+ ? new Response('', { status: 429 })
+ : new Response('{}', { status: 200 });
+ },
+ });
+
+ expect(calls).toBe(2);
+ expect(response.status).toBe(200);
+ });
+
+ it('gives up after a bounded number of attempts', async () => {
+ let calls = 0;
+ await expect(
+ hostedRequest({
+ ...base,
+ fetchImpl: async () => {
+ calls += 1;
+ return new Response('', { status: 503 });
+ },
+ })
+ ).rejects.toMatchObject({ kind: 'server' });
+
+ expect(calls).toBe(MAX_ATTEMPTS);
+ });
+
+ it('never retries an auth failure', async () => {
+ let calls = 0;
+ const error = await hostedRequest({
+ ...base,
+ fetchImpl: async () => {
+ calls += 1;
+ return new Response('', { status: 401 });
+ },
+ }).catch((err: unknown) => err);
+
+ expect(calls).toBe(1);
+ expect(isVoiceProviderError(error) && error.kind).toBe('auth');
+ expect(isVoiceProviderError(error) && error.sessionErrorCode).toBe('provider-auth-failed');
+ });
+
+ it('classifies quota, network, and request failures distinctly', async () => {
+ const quota = await hostedRequest({
+ ...base,
+ retry: false,
+ fetchImpl: async () => new Response('', { status: 402 }),
+ }).catch((err: unknown) => err);
+ expect(isVoiceProviderError(quota) && quota.sessionErrorCode).toBe('provider-quota-exceeded');
+
+ const network = await hostedRequest({
+ ...base,
+ retry: false,
+ fetchImpl: async () => {
+ throw new TypeError('fetch failed');
+ },
+ }).catch((err: unknown) => err);
+ expect(isVoiceProviderError(network) && network.sessionErrorCode).toBe(
+ 'provider-network-error'
+ );
+
+ const bad = await hostedRequest({
+ ...base,
+ retry: false,
+ fetchImpl: async () => new Response('', { status: 400 }),
+ }).catch((err: unknown) => err);
+ expect(isVoiceProviderError(bad) && bad.kind).toBe('request');
+ });
+
+ it('never quotes the response body, which can echo the key', async () => {
+ const error = await hostedRequest({
+ ...base,
+ retry: false,
+ fetchImpl: async () =>
+ new Response(JSON.stringify({ error: { message: `bad key ${KEY()}` } }), { status: 401 }),
+ }).catch((err: unknown) => err);
+
+ expect((error as Error).message).not.toContain(KEY());
+ });
+
+ it('aborts when the caller cancels', async () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(
+ hostedRequest({ ...base, signal: controller.signal, fetchImpl: async () => new Response() })
+ ).rejects.toMatchObject({ kind: 'network' });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// OpenAI STT
+// ---------------------------------------------------------------------------
+
+describe('OpenAiSttProvider', () => {
+ it('refuses to start without a key, before any audio is buffered', async () => {
+ const provider = new OpenAiSttProvider({ readCredential: () => null });
+ const { callbacks } = recorder();
+
+ await expect(provider.start(callbacks)).rejects.toThrow(/API key/i);
+ });
+
+ it('sends nothing until the floor has been opened', async () => {
+ const fetchImpl = vi.fn(async () => sseResponse([]));
+ const provider = new OpenAiSttProvider({ readCredential: KEY, fetchImpl });
+
+ // `feed` before `start`: audio that arrives with no session behind it must
+ // never reach a hosted service.
+ provider.feed(utterancePcm());
+ await provider.flush();
+
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('streams partials as deltas arrive and a final at the end', async () => {
+ const provider = new OpenAiSttProvider({
+ readCredential: KEY,
+ fetchImpl: async () =>
+ sseResponse([
+ { type: 'transcript.text.delta', delta: 'open the ' },
+ { type: 'transcript.text.delta', delta: 'auth tab' },
+ { type: 'transcript.text.done', text: 'open the auth tab' },
+ ]),
+ });
+ const { callbacks, partials, finals } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(utterancePcm());
+ await provider.flush();
+
+ expect(partials).toEqual(['open the ', 'open the auth tab']);
+ expect(finals).toEqual(['open the auth tab']);
+ });
+
+ it('reports a classified failure through onError rather than throwing', async () => {
+ const provider = new OpenAiSttProvider({
+ readCredential: KEY,
+ fetchImpl: async () => new Response('', { status: 429 }),
+ });
+ const { callbacks, errors } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(utterancePcm());
+ await provider.flush();
+
+ expect(errors).toHaveLength(1);
+ expect(isVoiceProviderError(errors[0]) && errors[0].kind).toBe('quota');
+ });
+
+ it('aborts the in-flight upload on stop', async () => {
+ let signal: AbortSignal | undefined;
+ const provider = new OpenAiSttProvider({
+ readCredential: KEY,
+ fetchImpl: async (_url, init) => {
+ signal = init?.signal ?? undefined;
+ return sseResponse([{ type: 'transcript.text.done', text: 'hello' }]);
+ },
+ });
+ const { callbacks } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(utterancePcm());
+ const inflight = provider.flush();
+ await provider.stop();
+ await inflight;
+
+ expect(signal?.aborted).toBe(true);
+ });
+
+ it('does not upload a cough', async () => {
+ const fetchImpl = vi.fn(async () => sseResponse([]));
+ const provider = new OpenAiSttProvider({ readCredential: KEY, fetchImpl });
+ const { callbacks } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(new Int16Array(100));
+ await provider.flush();
+
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('sends nothing for a client that did its own transcription', async () => {
+ const fetchImpl = vi.fn(async () => sseResponse([]));
+ const provider = new OpenAiSttProvider({ readCredential: KEY, fetchImpl });
+ const { callbacks, finals } = recorder();
+
+ await provider.start(callbacks);
+ provider.injectUtterance('typed instead of spoken');
+
+ expect(finals).toEqual(['typed instead of spoken']);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ElevenLabs TTS
+// ---------------------------------------------------------------------------
+
+describe('ElevenLabsTtsProvider', () => {
+ function pcmResponse(): Response {
+ return new Response(new Uint8Array(320).buffer, { status: 200 });
+ }
+
+ it('synthesises one chunk per sentence', async () => {
+ const fetchImpl = vi.fn(async () => pcmResponse());
+ const provider = new ElevenLabsTtsProvider({ readCredential: KEY, fetchImpl });
+
+ const chunks = [];
+ for await (const chunk of provider.speak('First one. Second one.', { utteranceId: 'u1' })) {
+ chunks.push(chunk);
+ }
+
+ expect(chunks.map((chunk) => chunk.text)).toEqual(['First one.', 'Second one.']);
+ expect(chunks[0].format).toBe('pcm16');
+ expect(chunks[0].sampleRate).toBe(16_000);
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('cancels an in-flight synthesis by aborting the request', async () => {
+ let signal: AbortSignal | undefined;
+ let started: () => void;
+ const requestStarted = new Promise((resolve) => {
+ started = resolve;
+ });
+
+ const provider = new ElevenLabsTtsProvider({
+ readCredential: KEY,
+ // A request that never settles on its own, so the only way this run can
+ // end is the abort. That is the property under test: a `cancel()` that
+ // merely stopped iterating would leave this socket open and the account
+ // paying for audio nobody will hear.
+ fetchImpl: (_url, init) =>
+ new Promise((_resolve, reject) => {
+ signal = init?.signal ?? undefined;
+ signal?.addEventListener('abort', () => reject(new Error('aborted')));
+ started();
+ }),
+ });
+
+ const chunks: TtsChunk[] = [];
+ const run = (async () => {
+ for await (const chunk of provider.speak('First. Second.', { utteranceId: 'u1' })) {
+ chunks.push(chunk);
+ }
+ })();
+
+ await requestStarted;
+ provider.cancel();
+ await run;
+
+ expect(signal?.aborted).toBe(true);
+ // The run ends quietly: a barge-in is not a failure to report.
+ expect(chunks).toHaveLength(0);
+ });
+
+ it('drops the rest of a run that was cancelled between sentences', async () => {
+ const provider = new ElevenLabsTtsProvider({
+ readCredential: KEY,
+ fetchImpl: async () => pcmResponse(),
+ });
+
+ const chunks = [];
+ for await (const chunk of provider.speak('First. Second. Third.', { utteranceId: 'u1' })) {
+ chunks.push(chunk);
+ provider.cancel();
+ }
+
+ expect(chunks).toHaveLength(1);
+ });
+
+ it('clamps the speed to the range the service accepts', async () => {
+ let body: Record = {};
+ const provider = new ElevenLabsTtsProvider({
+ readCredential: KEY,
+ fetchImpl: async (_url, init) => {
+ body = JSON.parse(String(init?.body)) as Record;
+ return pcmResponse();
+ },
+ });
+
+ for await (const _chunk of provider.speak('Hello.', { utteranceId: 'u1', rate: 9 })) {
+ // drain
+ }
+
+ expect((body.voice_settings as Record).speed).toBe(1.2);
+ });
+
+ it('classifies an auth failure rather than reporting a generic one', async () => {
+ const provider = new ElevenLabsTtsProvider({
+ readCredential: KEY,
+ fetchImpl: async () => new Response('', { status: 401 }),
+ });
+
+ const iterate = async () => {
+ for await (const _chunk of provider.speak('Hello.', { utteranceId: 'u1' })) {
+ // drain
+ }
+ };
+
+ await expect(iterate()).rejects.toMatchObject({ kind: 'auth' });
+ });
+
+ it('lists the voices on the account', async () => {
+ const provider = new ElevenLabsTtsProvider({
+ readCredential: KEY,
+ fetchImpl: async () =>
+ new Response(
+ JSON.stringify({
+ voices: [{ voice_id: 'v1', name: 'Rachel', labels: { accent: 'us' } }],
+ }),
+ { status: 200 }
+ ),
+ });
+
+ expect(await provider.listVoices()).toEqual([
+ { id: 'v1', name: 'Rachel', description: 'us', previewUrl: undefined },
+ ]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Hosted Brains
+// ---------------------------------------------------------------------------
+
+describe('hosted Brain providers', () => {
+ const decision = {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'new',
+ tabName: 'Auth',
+ prompt: 'refactor the auth module',
+ confidence: 0.9,
+ };
+
+ it('OpenAI routes through the shared parser', async () => {
+ const provider = new OpenAiBrainProvider({
+ readCredential: KEY,
+ fetchImpl: async () =>
+ new Response(
+ JSON.stringify({ choices: [{ message: { content: JSON.stringify(decision) } }] }),
+ { status: 200 }
+ ),
+ });
+
+ const result = await provider.route('ask backend to refactor auth', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(result).toMatchObject({ target: { sessionId: 'agent-backend' }, tabAction: 'new' });
+ });
+
+ it('Anthropic restores its JSON prefill before parsing', async () => {
+ const provider = new AnthropicBrainProvider({
+ readCredential: () => 'sk-ant-abcdefghijklmnop',
+ fetchImpl: async () =>
+ new Response(
+ // The prefill `{` is not echoed, so the body starts mid-object.
+ JSON.stringify({
+ content: [{ type: 'text', text: JSON.stringify(decision).slice(1) }],
+ }),
+ { status: 200 }
+ ),
+ });
+
+ const result = await provider.route('ask backend to refactor auth', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(result.target).toEqual({ sessionId: 'agent-backend' });
+ });
+
+ it('sends the conductor an utterance naming an agent that is not running', async () => {
+ const provider = new OpenAiBrainProvider({
+ readCredential: KEY,
+ fetchImpl: async () =>
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({ ...decision, target: { sessionId: 'ghost' } }),
+ },
+ },
+ ],
+ }),
+ { status: 200 }
+ ),
+ });
+
+ const result = await provider.route('ask ghost something', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ // A hallucinated id must never be dispatched to. The conductor takes it.
+ expect(result.target).toBe('conductor');
+ });
+
+ it('trims a spoken rewrite to the sentence budget', async () => {
+ const provider = new OpenAiBrainProvider({
+ readCredential: KEY,
+ fetchImpl: async () =>
+ new Response(
+ JSON.stringify({
+ choices: [{ message: { content: 'One. Two. Three. Four.' } }],
+ }),
+ { status: 200 }
+ ),
+ });
+
+ const spoken = await provider.converse('...', {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-auth',
+ maxSentences: 2,
+ });
+
+ expect(spoken).toBe('One. Two.');
+ });
+
+ it('refuses without a key before any request is made', async () => {
+ const fetchImpl = vi.fn();
+ const provider = new OpenAiBrainProvider({
+ readCredential: () => null,
+ fetchImpl: fetchImpl as never,
+ });
+
+ await expect(
+ provider.route('anything', { roster: ROSTER, scope: { kind: 'conductor' } })
+ ).rejects.toMatchObject({ kind: 'unavailable' });
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/main/acappella/providers/local-providers.test.ts b/src/__tests__/main/acappella/providers/local-providers.test.ts
new file mode 100644
index 0000000000..1c8d7b1274
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/local-providers.test.ts
@@ -0,0 +1,498 @@
+/**
+ * @file local-providers.test.ts
+ *
+ * The local tier against mocked native runtimes. Nothing here loads a model or a
+ * `.node` binary: the runtime loader is the seam, which is the whole reason it
+ * exists.
+ *
+ * What is worth testing about a local provider is not the inference - that
+ * belongs to whisper.cpp and llama.cpp - but everything around it, which is where
+ * this codebase's bugs would be: chunked partials that stop when the session
+ * does, a cancel that actually cuts a run, a model that unloads when idle and
+ * loads again on the next turn, and a load failure that reports itself as its own
+ * provider rather than reaching for another one.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('electron', () => ({ app: { getPath: () => '/tmp/maestro-test' } }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { WhisperSttProvider } from '../../../../main/acappella/providers/local/whisper-stt';
+import {
+ KokoroTtsProvider,
+ styleVectorFor,
+} from '../../../../main/acappella/providers/local/kokoro-tts';
+import { LlamaBrainProvider } from '../../../../main/acappella/providers/local/llama-brain';
+import { VoiceProviderError } from '../../../../shared/acappella/provider-errors';
+import type { SttCallbacks, TtsChunk } from '../../../../shared/acappella/providers';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [{ id: 'tab-auth', name: 'Auth', lastActiveAt: 1 }],
+ },
+];
+
+/** A runtime loader that always fails, as an uninstalled runtime would. */
+const failingRuntime = async (_id: string, providerId: string) => {
+ throw new VoiceProviderError('llama.cpp is not part of this build yet.', {
+ kind: 'unavailable',
+ providerId,
+ });
+};
+
+function recorder(): {
+ callbacks: SttCallbacks;
+ partials: string[];
+ finals: string[];
+ errors: Error[];
+} {
+ const partials: string[] = [];
+ const finals: string[] = [];
+ const errors: Error[] = [];
+ return {
+ partials,
+ finals,
+ errors,
+ callbacks: {
+ onPartial: (text) => partials.push(text),
+ onFinal: (text) => finals.push(text),
+ onError: (error) => errors.push(error),
+ },
+ };
+}
+
+/** 20 ms of 16 kHz audio. */
+function frame(): Int16Array {
+ return new Int16Array(320);
+}
+
+// ---------------------------------------------------------------------------
+// Whisper
+// ---------------------------------------------------------------------------
+
+describe('WhisperSttProvider', () => {
+ function whisperRuntime(segments: string[] = ['open the auth tab']) {
+ const free = vi.fn();
+ const transcribe = vi.fn(async () => ({
+ result: Promise.resolve(segments.map((text) => ({ text }))),
+ }));
+ const module = {
+ Whisper: class {
+ transcribe = transcribe;
+ free = free;
+ },
+ };
+ return { module, transcribe, free, loadRuntime: async () => module as never };
+ }
+
+ it('reports a runtime that will not load as its own failure, never another provider', async () => {
+ const provider = new WhisperSttProvider({ loadRuntime: failingRuntime as never });
+
+ await expect(provider.start(recorder().callbacks)).rejects.toMatchObject({
+ kind: 'unavailable',
+ providerId: 'whisper-local',
+ });
+ });
+
+ it('emits a partial once enough audio has accumulated', async () => {
+ const runtime = whisperRuntime(['open the']);
+ const provider = new WhisperSttProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/model.bin',
+ partialIntervalMs: 40,
+ });
+ const { callbacks, partials } = recorder();
+
+ await provider.start(callbacks);
+ // Two frames is 40 ms, which is the configured interval.
+ provider.feed(frame());
+ provider.feed(frame());
+ await vi.waitFor(() => expect(partials).toEqual(['open the']));
+ });
+
+ it('publishes the final on endpointing and clears the buffer', async () => {
+ const runtime = whisperRuntime(['open the auth tab']);
+ const provider = new WhisperSttProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/model.bin',
+ // No partials, so exactly one decode happens and it is the final.
+ partialIntervalMs: 0,
+ });
+ const { callbacks, finals } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(frame());
+ await provider.flush();
+
+ expect(finals).toEqual(['open the auth tab']);
+ expect(runtime.transcribe).toHaveBeenCalledTimes(1);
+
+ // A second flush with nothing buffered must not decode silence.
+ await provider.flush();
+ expect(runtime.transcribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a decode that lands after the session ended', async () => {
+ const runtime = whisperRuntime(['too late']);
+ const provider = new WhisperSttProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/model.bin',
+ partialIntervalMs: 0,
+ });
+ const { callbacks, finals } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(frame());
+ const pending = provider.flush();
+ await provider.stop();
+ await pending;
+
+ // The transcript belongs to a session that is over; publishing it would put
+ // an old utterance on the next turn.
+ expect(finals).toEqual([]);
+ });
+
+ it('frees the model on stop', async () => {
+ const runtime = whisperRuntime();
+ const provider = new WhisperSttProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/model.bin',
+ });
+
+ await provider.start(recorder().callbacks);
+ await provider.stop();
+
+ expect(runtime.free).toHaveBeenCalled();
+ });
+
+ it('reports a decode failure through onError rather than throwing at the frame path', async () => {
+ const module = {
+ Whisper: class {
+ transcribe = async () => {
+ throw new Error('ggml assert');
+ };
+ free = vi.fn();
+ },
+ };
+ const provider = new WhisperSttProvider({
+ loadRuntime: async () => module as never,
+ modelPath: '/tmp/model.bin',
+ partialIntervalMs: 0,
+ });
+ const { callbacks, errors } = recorder();
+
+ await provider.start(callbacks);
+ provider.feed(frame());
+ await provider.flush();
+
+ expect(errors[0].message).toContain('ggml assert');
+ });
+
+ it('takes typed text without decoding anything', async () => {
+ const runtime = whisperRuntime();
+ const provider = new WhisperSttProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/model.bin',
+ });
+ const { callbacks, finals } = recorder();
+
+ await provider.start(callbacks);
+ provider.injectUtterance('typed instead of spoken');
+
+ expect(finals).toEqual(['typed instead of spoken']);
+ expect(runtime.transcribe).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Kokoro
+// ---------------------------------------------------------------------------
+
+describe('KokoroTtsProvider', () => {
+ function onnxRuntime() {
+ const run = vi.fn(async () => ({ waveform: { data: new Float32Array(240), dims: [1, 240] } }));
+ const release = vi.fn();
+ const module = {
+ InferenceSession: { create: async () => ({ run, release }) },
+ Tensor: class {
+ constructor(
+ readonly type: string,
+ readonly data: unknown,
+ readonly dims: readonly number[]
+ ) {}
+ },
+ };
+ return { module, run, release, loadRuntime: async () => module as never };
+ }
+
+ const phonemize = (text: string) => text.split('').map((_char, index) => index + 1);
+ const readVoicePack = async () => new Float32Array(256 * 64);
+
+ async function collect(iterable: AsyncIterable): Promise {
+ const chunks: TtsChunk[] = [];
+ for await (const chunk of iterable) chunks.push(chunk);
+ return chunks;
+ }
+
+ it('refuses, by name, when there is no phoneme front end', async () => {
+ const runtime = onnxRuntime();
+ const provider = new KokoroTtsProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/kokoro.onnx',
+ readVoicePack,
+ });
+
+ // It does NOT approximate. A character-level fallback would synthesise
+ // confident nonsense, which is worse than silence with an explanation.
+ await expect(collect(provider.speak('Hello.', { utteranceId: 'u1' }))).rejects.toMatchObject({
+ kind: 'unavailable',
+ });
+ });
+
+ it('synthesises one chunk per sentence at the model rate', async () => {
+ const runtime = onnxRuntime();
+ const provider = new KokoroTtsProvider({
+ phonemize,
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/kokoro.onnx',
+ voicePackPath: '/tmp/voice.bin',
+ readVoicePack,
+ });
+
+ const chunks = await collect(provider.speak('First one. Second one.', { utteranceId: 'u1' }));
+
+ expect(chunks.map((chunk) => chunk.text)).toEqual(['First one.', 'Second one.']);
+ expect(chunks[0].sampleRate).toBe(24_000);
+ expect(runtime.run).toHaveBeenCalledTimes(2);
+ });
+
+ it('cancels a run without delivering the sentence it interrupted', async () => {
+ const runtime = onnxRuntime();
+ const provider = new KokoroTtsProvider({
+ phonemize,
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/kokoro.onnx',
+ voicePackPath: '/tmp/voice.bin',
+ readVoicePack,
+ });
+
+ const chunks: TtsChunk[] = [];
+ for await (const chunk of provider.speak('First. Second. Third.', { utteranceId: 'u1' })) {
+ chunks.push(chunk);
+ provider.cancel();
+ }
+
+ expect(chunks).toHaveLength(1);
+ });
+
+ it('clamps the speed to the range the model can render', async () => {
+ const runtime = onnxRuntime();
+ const provider = new KokoroTtsProvider({
+ phonemize,
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/kokoro.onnx',
+ voicePackPath: '/tmp/voice.bin',
+ readVoicePack,
+ });
+
+ await collect(provider.speak('Hello.', { utteranceId: 'u1', rate: 9 }));
+
+ const feeds = (
+ runtime.run.mock.calls[0] as unknown as [Record]
+ )[0];
+ expect(feeds.speed.data[0]).toBe(2);
+ });
+
+ it('releases the session when disposed', async () => {
+ const runtime = onnxRuntime();
+ const provider = new KokoroTtsProvider({
+ phonemize,
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/kokoro.onnx',
+ voicePackPath: '/tmp/voice.bin',
+ readVoicePack,
+ });
+
+ await collect(provider.speak('Hello.', { utteranceId: 'u1' }));
+ await provider.dispose();
+
+ expect(runtime.release).toHaveBeenCalled();
+ });
+
+ it('clamps a style row rather than reading past the end of the pack', () => {
+ const pack = new Float32Array(256 * 4).fill(1);
+ // A sentence longer than the pack has rows for: reading past the end would
+ // hand the model whatever followed it in memory, which comes out as a burst
+ // of noise at full volume.
+ expect(styleVectorFor(pack, 10_000)).toHaveLength(256);
+ expect(styleVectorFor(pack, 0)).toHaveLength(256);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Qwen3 via llama.cpp
+// ---------------------------------------------------------------------------
+
+describe('LlamaBrainProvider', () => {
+ function llamaRuntime(reply: string) {
+ const prompt = vi.fn(async () => reply);
+ const contextDispose = vi.fn();
+ const modelDispose = vi.fn();
+ const module = {
+ getLlama: async () => ({
+ loadModel: async () => ({
+ createContext: async () => ({
+ getSequence: () => ({}),
+ dispose: contextDispose,
+ }),
+ dispose: modelDispose,
+ }),
+ createGrammarForJsonSchema: async () => ({}),
+ }),
+ LlamaChatSession: class {
+ prompt = prompt;
+ },
+ };
+ return {
+ module,
+ prompt,
+ contextDispose,
+ modelDispose,
+ loadRuntime: async () => module as never,
+ };
+ }
+
+ const decision = JSON.stringify({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'new',
+ tabName: 'Auth',
+ prompt: 'refactor auth',
+ confidence: 0.9,
+ });
+
+ it('reports a runtime that will not load as its own failure', async () => {
+ const provider = new LlamaBrainProvider({ loadRuntime: failingRuntime as never });
+
+ await expect(
+ provider.route('anything', { roster: ROSTER, scope: { kind: 'conductor' } })
+ ).rejects.toMatchObject({ kind: 'unavailable', providerId: 'qwen3-local' });
+ });
+
+ it('routes through the shared parser', async () => {
+ const runtime = llamaRuntime(decision);
+ const provider = new LlamaBrainProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/qwen.gguf',
+ idleUnloadMs: 0,
+ });
+
+ const result = await provider.route('ask backend to refactor auth', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(result).toMatchObject({ target: { sessionId: 'agent-backend' }, tabAction: 'new' });
+ });
+
+ it('keeps the context loaded across turns', async () => {
+ const runtime = llamaRuntime(decision);
+ const provider = new LlamaBrainProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/qwen.gguf',
+ // Never unload, so this test is about reuse rather than about the timer.
+ idleUnloadMs: 0,
+ });
+ const context = { roster: ROSTER, scope: { kind: 'conductor' as const } };
+
+ await provider.route('one', context);
+ await provider.route('two', context);
+
+ expect(provider.isLoaded).toBe(true);
+ expect(runtime.prompt).toHaveBeenCalledTimes(2);
+ // One load, two turns: per-turn latency is inference only.
+ expect(runtime.modelDispose).not.toHaveBeenCalled();
+ });
+
+ it('unloads after the idle window and reloads on the next turn', async () => {
+ vi.useFakeTimers();
+ try {
+ const runtime = llamaRuntime(decision);
+ const provider = new LlamaBrainProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/qwen.gguf',
+ idleUnloadMs: 1_000,
+ });
+ const context = { roster: ROSTER, scope: { kind: 'conductor' as const } };
+
+ await provider.route('one', context);
+ expect(provider.isLoaded).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(1_100);
+ // A gigabyte of resident memory for a conversation that ended is not
+ // acceptable, however warm it would have been.
+ expect(provider.isLoaded).toBe(false);
+ expect(runtime.contextDispose).toHaveBeenCalled();
+ expect(runtime.modelDispose).toHaveBeenCalled();
+
+ await provider.route('two', context);
+ expect(provider.isLoaded).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('trims a spoken rewrite to the sentence budget', async () => {
+ const runtime = llamaRuntime('One. Two. Three.');
+ const provider = new LlamaBrainProvider({
+ loadRuntime: runtime.loadRuntime,
+ modelPath: '/tmp/qwen.gguf',
+ idleUnloadMs: 0,
+ });
+
+ const spoken = await provider.converse('...', {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-auth',
+ maxSentences: 2,
+ });
+
+ expect(spoken).toBe('One. Two.');
+ });
+
+ it('reports an inference failure as a classified provider error', async () => {
+ const module = {
+ getLlama: async () => ({
+ loadModel: async () => ({
+ createContext: async () => ({ getSequence: () => ({}), dispose: vi.fn() }),
+ dispose: vi.fn(),
+ }),
+ createGrammarForJsonSchema: async () => ({}),
+ }),
+ LlamaChatSession: class {
+ prompt = async () => {
+ throw new Error('context is full');
+ };
+ },
+ };
+ const provider = new LlamaBrainProvider({
+ loadRuntime: async () => module as never,
+ modelPath: '/tmp/qwen.gguf',
+ idleUnloadMs: 0,
+ });
+
+ await expect(
+ provider.route('anything', { roster: ROSTER, scope: { kind: 'conductor' } })
+ ).rejects.toMatchObject({ kind: 'unavailable' });
+ });
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
diff --git a/src/__tests__/main/acappella/providers/provider-registry.test.ts b/src/__tests__/main/acappella/providers/provider-registry.test.ts
new file mode 100644
index 0000000000..7c7bb1abd8
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/provider-registry.test.ts
@@ -0,0 +1,540 @@
+/**
+ * @file provider-registry.test.ts
+ *
+ * The rules the registry exists to enforce, one test each:
+ *
+ * - Every slot combination resolves to what was ASKED for, independently.
+ * - A missing local provider NEVER resolves to a hosted one. This is the test
+ * that would fail if someone added a "helpful" fallback, and it is checked
+ * exhaustively across every combination rather than on one example, because a
+ * fallback added for one role would be trivially easy to miss on another.
+ * - The mock tier is selected explicitly and is never substituted in.
+ * - A hot swap tears the old pipeline down, and is refused mid-utterance.
+ *
+ * The concrete providers are stubbed at the module boundary. Constructing a real
+ * one would try to open a keychain and a native runtime, and none of that is what
+ * these tests are about.
+ */
+
+import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+// Every real backend is replaced with an inert stand-in: these tests are about
+// which one is chosen, and a real one would reach for a keychain entry, a model
+// file, or a native addon at construction.
+vi.mock('../../../../main/acappella/providers/local/whisper-stt', () => ({
+ WhisperSttProvider: class {
+ readonly id = 'whisper-local';
+ readonly label = 'Whisper (local)';
+ readonly tier = 'local';
+ readonly sampleRate = 16_000;
+ readonly acceptsAudio = true;
+ async start() {}
+ feed() {}
+ async flush() {}
+ async stop() {}
+ },
+}));
+vi.mock('../../../../main/acappella/providers/local/kokoro-tts', () => ({
+ KokoroTtsProvider: class {
+ readonly id = 'kokoro-local';
+ readonly label = 'Kokoro (local)';
+ readonly tier = 'local';
+ speak() {
+ return (async function* () {})();
+ }
+ cancel() {}
+ },
+}));
+vi.mock('../../../../main/acappella/providers/local/llama-brain', () => ({
+ LlamaBrainProvider: class {
+ readonly id = 'qwen3-local';
+ readonly label = 'Qwen3 1.7B (local)';
+ readonly tier = 'local';
+ async route() {
+ return { target: 'conductor', tabAction: 'current', prompt: '', confidence: 1 };
+ }
+ async converse() {
+ return '';
+ }
+ },
+}));
+vi.mock('../../../../main/acappella/providers/hosted/openai-stt', () => ({
+ OpenAiSttProvider: class {
+ readonly id = 'openai-stt';
+ readonly label = 'OpenAI (hosted)';
+ readonly tier = 'cloud';
+ readonly sampleRate = 16_000;
+ readonly acceptsAudio = true;
+ async start() {}
+ feed() {}
+ async flush() {}
+ async stop() {}
+ },
+}));
+vi.mock('../../../../main/acappella/providers/hosted/elevenlabs-tts', () => ({
+ ElevenLabsTtsProvider: class {
+ readonly id = 'elevenlabs-tts';
+ readonly label = 'ElevenLabs (hosted)';
+ readonly tier = 'cloud';
+ speak() {
+ return (async function* () {})();
+ }
+ cancel() {}
+ },
+}));
+vi.mock('../../../../main/acappella/providers/hosted/openai-brain', () => ({
+ OpenAiBrainProvider: class {
+ readonly id = 'openai-brain';
+ readonly label = 'OpenAI (hosted)';
+ readonly tier = 'cloud';
+ async route() {
+ return { target: 'conductor', tabAction: 'current', prompt: '', confidence: 1 };
+ }
+ async converse() {
+ return '';
+ }
+ },
+}));
+vi.mock('../../../../main/acappella/providers/hosted/anthropic-brain', () => ({
+ AnthropicBrainProvider: class {
+ readonly id = 'anthropic-brain';
+ readonly label = 'Anthropic (hosted)';
+ readonly tier = 'cloud';
+ async route() {
+ return { target: 'conductor', tabAction: 'current', prompt: '', confidence: 1 };
+ }
+ async converse() {
+ return '';
+ }
+ },
+}));
+
+const realtimeDispose = vi.fn(async () => {});
+vi.mock('../../../../main/acappella/providers/realtime/realtime-session', () => {
+ const adapter = {
+ id: 'openai-realtime',
+ label: 'OpenAI Realtime',
+ tier: 'cloud',
+ sampleRate: 16_000,
+ acceptsAudio: true,
+ };
+ return {
+ createRealtimePipeline: () => ({
+ shape: 'realtime',
+ providers: { stt: adapter, tts: adapter, brain: adapter },
+ dispose: realtimeDispose,
+ }),
+ };
+});
+
+import { logger } from '../../../../main/utils/logger';
+import {
+ MockBrainProvider,
+ MockSttProvider,
+ MockTtsProvider,
+} from '../../../../main/acappella/providers/mock';
+import { ECHO_STT_PROVIDER_ID } from '../../../../main/acappella/providers/echo-stt';
+import {
+ DEFAULT_PROVIDER_IDS,
+ MOCK_PROVIDER_IDS,
+ buildProviderState,
+ listVoiceProviders,
+ pipelineKey,
+ readVoiceProviderSettings,
+ registerVoiceProvider,
+ resolveVoicePipeline,
+ resolveVoiceProviders,
+ swapVoicePipeline,
+} from '../../../../main/acappella/providers/provider-registry';
+import { unresolvedProviderId } from '../../../../main/acappella/providers/unresolved';
+import type { VoiceProviderRole } from '../../../../shared/acappella/providers';
+import {
+ DEFAULT_SEND_HOLD_MS,
+ DEFAULT_TURN_SETTLE_MS,
+} from '../../../../shared/acappella/voice-controls';
+
+describe('provider registry', () => {
+ beforeAll(() => {
+ registerVoiceProvider({
+ role: 'tts',
+ id: 'test-local-tts',
+ label: 'Test Local TTS',
+ tier: 'local',
+ create: () => new MockTtsProvider({ msPerCharacter: 0 }),
+ });
+ registerVoiceProvider({
+ role: 'stt',
+ id: 'test-cloud-stt',
+ label: 'Test Cloud STT',
+ tier: 'cloud',
+ create: () => new MockSttProvider({ partialDelayMs: 0 }),
+ });
+ registerVoiceProvider({
+ role: 'brain',
+ id: 'test-unavailable-brain',
+ label: 'Test Unavailable Brain',
+ tier: 'local',
+ isAvailable: () => false,
+ create: () => new MockBrainProvider(),
+ });
+ });
+
+ beforeEach(() => {
+ vi.mocked(logger.warn).mockClear();
+ realtimeDispose.mockClear();
+ });
+
+ // -- Resolution ----------------------------------------------------------
+
+ it('defaults to a trio that can hear, and says nothing about it', () => {
+ const { providers, substitutions, resolvedIds } = resolveVoiceProviders();
+
+ // STT is the exception to the mock default, and the important one: an
+ // unconfigured install has to be able to establish that its microphone
+ // reaches the app at all.
+ expect(resolvedIds.stt).toBe(ECHO_STT_PROVIDER_ID);
+ expect(providers.stt.acceptsAudio).toBe(true);
+ expect(resolvedIds.tts).toBe(MOCK_PROVIDER_IDS.tts);
+ expect(resolvedIds.brain).toBe(MOCK_PROVIDER_IDS.brain);
+ // The default path is documented behaviour, not something to warn about.
+ expect(substitutions).toEqual([]);
+ });
+
+ it('hands out a fresh trio each time', () => {
+ expect(resolveVoiceProviders().providers.stt).not.toBe(resolveVoiceProviders().providers.stt);
+ });
+
+ it('resolves every slot combination to exactly what was asked for', () => {
+ const choices: Record = {
+ stt: ['mock-stt', 'whisper-local', 'openai-stt'],
+ tts: ['mock-tts', 'kokoro-local', 'elevenlabs-tts'],
+ brain: ['mock-brain', 'qwen3-local', 'openai-brain', 'anthropic-brain'],
+ };
+
+ for (const stt of choices.stt) {
+ for (const tts of choices.tts) {
+ for (const brain of choices.brain) {
+ const { resolvedIds, substitutions } = resolveVoicePipeline({
+ settings: { stt, tts, brain },
+ });
+ expect(resolvedIds).toEqual({ stt, tts, brain });
+ expect(substitutions).toEqual([]);
+ }
+ }
+ }
+ });
+
+ it('never resolves a missing local provider to a hosted one', () => {
+ // Hosted providers for every role are registered and perfectly usable. None
+ // of them may be chosen for a local id that does not exist.
+ const missing: Record = {
+ stt: 'whisper-that-is-not-registered',
+ tts: 'kokoro-that-is-not-registered',
+ brain: 'qwen-that-is-not-registered',
+ };
+
+ for (const role of ['stt', 'tts', 'brain'] as VoiceProviderRole[]) {
+ const { providers, resolvedIds, substitutions } = resolveVoicePipeline({
+ settings: { [role]: missing[role] },
+ });
+
+ expect(resolvedIds[role]).toBe(unresolvedProviderId(role));
+ expect(providers[role].tier).not.toBe('cloud');
+ expect(substitutions).toEqual([
+ expect.objectContaining({ role, requestedId: missing[role], reason: 'unknown-provider' }),
+ ]);
+ expect(logger.warn).toHaveBeenCalled();
+ }
+ });
+
+ it('refuses by name rather than falling back to the mock', async () => {
+ const { providers } = resolveVoicePipeline({ settings: { stt: 'not-a-provider' } });
+
+ // The distinction that matters: a mock STT would "work" and transcribe
+ // nothing, which is indistinguishable from a broken feature.
+ expect(providers.stt.tier).not.toBe('mock');
+ await expect(
+ providers.stt.start({ onPartial: () => {}, onFinal: () => {}, onError: () => {} })
+ ).rejects.toThrow(/not-a-provider/);
+ });
+
+ it('marks a registered but unrunnable provider unavailable, not unknown', () => {
+ const { substitutions } = resolveVoicePipeline({
+ settings: { brain: 'test-unavailable-brain' },
+ });
+
+ expect(substitutions[0].reason).toBe('unavailable');
+ });
+
+ it('substitutes only the broken role and leaves the others alone', () => {
+ const { resolvedIds, substitutions } = resolveVoicePipeline({
+ settings: { stt: 'test-missing', tts: 'test-local-tts' },
+ });
+
+ expect(resolvedIds.stt).toBe(unresolvedProviderId('stt'));
+ expect(resolvedIds.tts).toBe('test-local-tts');
+ expect(resolvedIds.brain).toBe(MOCK_PROVIDER_IDS.brain);
+ expect(substitutions).toHaveLength(1);
+ });
+
+ it.each(['development', 'production'])(
+ 'defaults STT to a provider that consumes audio in a %s build',
+ (env) => {
+ // The microphone check used to be development-only, which left a packaged
+ // app with NO provider that opens a capture device: the session reported
+ // "Listening" and the microphone was never touched. The build must not
+ // decide whether the user can hear themselves.
+ const previous = process.env.NODE_ENV;
+ process.env.NODE_ENV = env;
+ try {
+ const { resolvedIds, providers, substitutions } = resolveVoicePipeline();
+
+ expect(resolvedIds.stt).toBe(ECHO_STT_PROVIDER_ID);
+ expect(providers.stt.acceptsAudio).toBe(true);
+ // Nobody asked for it, so it is a default rather than a substitution.
+ expect(substitutions).toEqual([]);
+ expect(DEFAULT_PROVIDER_IDS.stt).toBe(ECHO_STT_PROVIDER_ID);
+ } finally {
+ process.env.NODE_ENV = previous;
+ }
+ }
+ );
+
+ it('reports a DEFAULT that fell back to the mock instead of falling back silently', () => {
+ // The silence was the bug. A slot that lands on a text-in recogniser nobody
+ // chose is the one fact that explains a session which cannot hear, and it
+ // used to exist only inside the resolver.
+ const previous = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ registerVoiceProvider({
+ role: 'tts',
+ id: 'test-unavailable-default-tts',
+ label: 'Unavailable',
+ tier: 'local',
+ isAvailable: () => false,
+ create: () => ({}) as never,
+ });
+ const restoreDefault = DEFAULT_PROVIDER_IDS.tts;
+ DEFAULT_PROVIDER_IDS.tts = 'test-unavailable-default-tts';
+ try {
+ const { resolvedIds, substitutions } = resolveVoicePipeline();
+
+ expect(resolvedIds.tts).toBe(MOCK_PROVIDER_IDS.tts);
+ expect(substitutions).toEqual([
+ expect.objectContaining({
+ role: 'tts',
+ requestedId: 'test-unavailable-default-tts',
+ resolvedId: MOCK_PROVIDER_IDS.tts,
+ reason: 'unavailable',
+ }),
+ ]);
+ } finally {
+ DEFAULT_PROVIDER_IDS.tts = restoreDefault;
+ process.env.NODE_ENV = previous;
+ }
+ });
+
+ it('lists the mock tier as selectable and available', () => {
+ expect(listVoiceProviders('stt')).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: MOCK_PROVIDER_IDS.stt, tier: 'mock', available: true }),
+ ])
+ );
+ });
+
+ // -- Pipeline shape ------------------------------------------------------
+
+ it('builds a cascade pipeline by default', () => {
+ const { shape, pipeline } = resolveVoicePipeline();
+ expect(shape).toBe('cascade');
+ expect(pipeline.shape).toBe('cascade');
+ });
+
+ it('builds a realtime pipeline with one adapter in all three slots', () => {
+ const { shape, providers } = resolveVoicePipeline({ settings: { pipeline: 'realtime' } });
+
+ expect(shape).toBe('realtime');
+ expect(providers.stt).toBe(providers.tts);
+ expect(providers.tts).toBe(providers.brain);
+ });
+
+ it('refuses an unknown realtime provider rather than using the one that exists', () => {
+ const { shape, resolvedIds, substitutions } = resolveVoicePipeline({
+ settings: { pipeline: 'realtime', realtime: 'some-other-realtime' },
+ });
+
+ expect(shape).toBe('cascade');
+ expect(resolvedIds.stt).toBe(unresolvedProviderId('stt'));
+ expect(substitutions).toHaveLength(3);
+ });
+
+ // -- Provider state ------------------------------------------------------
+
+ it('describes the live engines and where audio goes', () => {
+ const resolution = resolveVoicePipeline({
+ settings: { stt: 'whisper-local', tts: 'kokoro-local', brain: 'qwen3-local' },
+ });
+
+ const state = buildProviderState(resolution);
+ expect(state.pipeline).toBe('cascade');
+ expect(state.audioLeavesMachine).toBe(false);
+ expect(state.egressStatement).toBe('Audio stays on this machine.');
+ expect(state.slots.map((slot) => slot.providerId)).toEqual([
+ 'whisper-local',
+ 'kokoro-local',
+ 'qwen3-local',
+ ]);
+ });
+
+ it('says where audio goes when the recogniser is hosted', () => {
+ const state = buildProviderState(
+ resolveVoicePipeline({ settings: { stt: 'openai-stt', tts: 'kokoro-local' } })
+ );
+
+ expect(state.audioLeavesMachine).toBe(true);
+ expect(state.egressStatement).toBe('Audio is sent to OpenAI.');
+ });
+
+ it('reports a substituted slot rather than presenting it as configured', () => {
+ const state = buildProviderState(resolveVoicePipeline({ settings: { tts: 'gone' } }));
+ const tts = state.slots.find((slot) => slot.role === 'tts')!;
+
+ expect(tts.substitutedFor).toBe('gone');
+ // A slot that resolved to nothing sends nothing anywhere, whatever it was
+ // configured with.
+ expect(state.audioLeavesMachine).toBe(false);
+ });
+
+ // -- Hot swap ------------------------------------------------------------
+
+ it('does nothing when the selection has not changed', async () => {
+ const settings = { stt: 'whisper-local' };
+ const current = resolveVoicePipeline({ settings });
+ const dispose = vi.spyOn(current.pipeline, 'dispose');
+
+ const result = await swapVoicePipeline({
+ settings,
+ current: { pipeline: current.pipeline, key: pipelineKey(settings) },
+ isBusy: false,
+ });
+
+ expect(result.status).toBe('unchanged');
+ expect(dispose).not.toHaveBeenCalled();
+ });
+
+ it('tears the old pipeline down before building the new one', async () => {
+ const current = resolveVoicePipeline({ settings: { stt: 'whisper-local' } });
+ const dispose = vi.spyOn(current.pipeline, 'dispose');
+
+ const result = await swapVoicePipeline({
+ settings: { stt: 'openai-stt' },
+ current: { pipeline: current.pipeline, key: pipelineKey({ stt: 'whisper-local' }) },
+ isBusy: false,
+ });
+
+ expect(dispose).toHaveBeenCalledTimes(1);
+ expect(result.status).toBe('swapped');
+ expect(result.resolution?.resolvedIds.stt).toBe('openai-stt');
+ });
+
+ it('refuses a swap mid-utterance and leaves the live pipeline alone', async () => {
+ const current = resolveVoicePipeline({ settings: { stt: 'whisper-local' } });
+ const dispose = vi.spyOn(current.pipeline, 'dispose');
+
+ const result = await swapVoicePipeline({
+ settings: { stt: 'openai-stt' },
+ current: { pipeline: current.pipeline, key: pipelineKey({ stt: 'whisper-local' }) },
+ isBusy: true,
+ });
+
+ expect(result.status).toBe('refused');
+ expect(result.reason).toMatch(/middle of a turn/i);
+ // The important half: nothing was torn down, so the turn in flight still has
+ // the engines it started with.
+ expect(dispose).not.toHaveBeenCalled();
+ expect(result.resolution).toBeUndefined();
+ });
+
+ it('disposes a realtime pipeline on swap, closing its socket', async () => {
+ const current = resolveVoicePipeline({ settings: { pipeline: 'realtime' } });
+
+ await swapVoicePipeline({
+ settings: { pipeline: 'cascade' },
+ current: { pipeline: current.pipeline, key: pipelineKey({ pipeline: 'realtime' }) },
+ isBusy: false,
+ });
+
+ expect(realtimeDispose).toHaveBeenCalledTimes(1);
+ });
+
+ // -- Settings ------------------------------------------------------------
+
+ it('reads provider ids out of settings and ignores malformed values', () => {
+ const store = {
+ get: () => ({
+ providers: { stt: ' whisper-local ', tts: 42, brain: ' ' },
+ pipeline: 'realtime',
+ voice: { voiceId: 'af_heart', rate: 1.1 },
+ }),
+ };
+
+ expect(readVoiceProviderSettings(store)).toEqual({
+ stt: 'whisper-local',
+ tts: undefined,
+ brain: undefined,
+ pipeline: 'realtime',
+ realtime: undefined,
+ voiceId: 'af_heart',
+ rate: 1.1,
+ // Clamped rather than passed through: this becomes a gain on a live
+ // output node, so an absent or nonsensical value reads as full volume.
+ volume: 1,
+ // Same reasoning, different consequence: this one becomes a timer in
+ // front of every dispatch, so an absent value reads as the default wait
+ // rather than as zero.
+ turnSettleMs: DEFAULT_TURN_SETTLE_MS,
+ // Off unless explicitly stored: conversational mode changes what a
+ // spoken sentence means, so it is never on by inference.
+ conversationalMode: false,
+ holdUntilSend: false,
+ sendHoldMs: DEFAULT_SEND_HOLD_MS,
+ // Absent takes the built-in phrases; a stored empty string is a
+ // deliberate "no spoken send", so the two stay distinguishable.
+ sendPhrases: undefined,
+ });
+ });
+
+ it('treats a missing settings key as unset and the shape as cascade', () => {
+ const store = { get: (_key: string, defaultValue: unknown) => defaultValue };
+
+ expect(readVoiceProviderSettings(store)).toEqual({
+ stt: undefined,
+ tts: undefined,
+ brain: undefined,
+ pipeline: 'cascade',
+ realtime: undefined,
+ voiceId: undefined,
+ rate: undefined,
+ volume: 1,
+ turnSettleMs: DEFAULT_TURN_SETTLE_MS,
+ conversationalMode: false,
+ holdUntilSend: false,
+ sendHoldMs: DEFAULT_SEND_HOLD_MS,
+ // Absent takes the built-in phrases; a stored empty string is a
+ // deliberate "no spoken send", so the two stay distinguishable.
+ sendPhrases: undefined,
+ });
+ });
+
+ it('keys a selection so an unchanged one is recognised', () => {
+ expect(pipelineKey({ stt: 'a', tts: 'b', brain: 'c' })).toBe(
+ pipelineKey({ stt: 'a', tts: 'b', brain: 'c' })
+ );
+ expect(pipelineKey({ stt: 'a' })).not.toBe(pipelineKey({ stt: 'b' }));
+ expect(pipelineKey({ voiceId: 'x' })).not.toBe(pipelineKey({ voiceId: 'y' }));
+ });
+});
diff --git a/src/__tests__/main/acappella/providers/realtime-session.test.ts b/src/__tests__/main/acappella/providers/realtime-session.test.ts
new file mode 100644
index 0000000000..e80f44bf18
--- /dev/null
+++ b/src/__tests__/main/acappella/providers/realtime-session.test.ts
@@ -0,0 +1,315 @@
+/**
+ * @file realtime-session.test.ts
+ *
+ * The two properties that make the realtime tier safe to swap in:
+ *
+ * 1. **Routing stays Maestro's.** The model asks for an agent and a tab through
+ * a tool call; Maestro validates it against the live roster and performs it.
+ * A model that names an agent nobody is running does NOT get to dispatch
+ * there, exactly as in the cascade.
+ * 2. **Interruption propagates.** Barge-in has to reach the SERVER, cancelling
+ * generation and dropping audio already queued there. A cancel that only
+ * stopped the local iterator would leave the assistant talking over the
+ * person who interrupted it.
+ *
+ * The socket is injected, so the whole protocol is exercised with no network and
+ * no API key.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import {
+ RealtimePipeline,
+ RealtimeVoiceAdapter,
+ ROUTE_TOOL_NAME,
+ type RealtimeSocket,
+} from '../../../../main/acappella/providers/realtime/realtime-session';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+import type { SttCallbacks, TtsChunk } from '../../../../shared/acappella/providers';
+
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [{ id: 'tab-auth', name: 'Auth', lastActiveAt: 1 }],
+ },
+];
+
+/** An in-memory socket that records what was sent and can push events back. */
+class FakeSocket implements RealtimeSocket {
+ readonly sent: Array> = [];
+ closed = false;
+ private handlers: Record void>> = {};
+
+ send(data: string): void {
+ this.sent.push(JSON.parse(data) as Record);
+ }
+
+ close(): void {
+ this.closed = true;
+ this.emit('close');
+ }
+
+ on(event: string, handler: (...args: never[]) => void): void {
+ (this.handlers[event] ??= []).push(handler);
+ }
+
+ emit(event: string, ...args: unknown[]): void {
+ for (const handler of this.handlers[event] ?? []) {
+ (handler as (...inner: unknown[]) => void)(...args);
+ }
+ }
+
+ /** Push one server event, as the API would. */
+ server(payload: Record): void {
+ this.emit('message', JSON.stringify(payload));
+ }
+
+ /** Everything sent of a given type. */
+ sentOfType(type: string): Array> {
+ return this.sent.filter((message) => message.type === type);
+ }
+}
+
+function noopCallbacks(): SttCallbacks {
+ return { onPartial: () => {}, onFinal: () => {}, onError: () => {} };
+}
+
+let socket: FakeSocket;
+
+/** Build an adapter over a socket that opens on the next tick. */
+async function startAdapter(callbacks: SttCallbacks = noopCallbacks()) {
+ socket = new FakeSocket();
+ const adapter = new RealtimeVoiceAdapter({
+ readCredential: () => 'sk-test-abcdefghijklmnop',
+ socketFactory: () => socket,
+ routeTimeoutMs: 50,
+ responseTimeoutMs: 50,
+ });
+
+ const started = adapter.start(callbacks);
+ socket.emit('open');
+ await started;
+ return adapter;
+}
+
+beforeEach(() => {
+ socket = new FakeSocket();
+});
+
+describe('RealtimeVoiceAdapter', () => {
+ it('declares the routing tool on the session', async () => {
+ await startAdapter();
+
+ const update = socket.sentOfType('session.update')[0];
+ const session = update.session as { tools: Array<{ name: string }> };
+ expect(session.tools.map((tool) => tool.name)).toEqual([ROUTE_TOOL_NAME]);
+ });
+
+ it('refuses to connect without a key', async () => {
+ const adapter = new RealtimeVoiceAdapter({
+ readCredential: () => null,
+ socketFactory: () => new FakeSocket(),
+ });
+
+ await expect(adapter.start(noopCallbacks())).rejects.toThrow(/API key/i);
+ });
+
+ it('turns the transcription events into partials and a final', async () => {
+ const partials: string[] = [];
+ const finals: string[] = [];
+ await startAdapter({
+ onPartial: (text) => partials.push(text),
+ onFinal: (text) => finals.push(text),
+ onError: () => {},
+ });
+
+ socket.server({ type: 'conversation.item.input_audio_transcription.delta', delta: 'open ' });
+ socket.server({
+ type: 'conversation.item.input_audio_transcription.completed',
+ transcript: 'open the auth tab',
+ });
+
+ expect(partials).toEqual(['open ']);
+ expect(finals).toEqual(['open the auth tab']);
+ });
+
+ it('executes a tool-call route decision through the shared parser', async () => {
+ const adapter = await startAdapter();
+
+ const routing = adapter.route('ask backend about auth', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ socket.server({
+ type: 'response.function_call_arguments.done',
+ arguments: JSON.stringify({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ tabId: 'tab-auth',
+ prompt: 'what happened to auth',
+ confidence: 0.8,
+ }),
+ });
+
+ // The decision Maestro will EXECUTE, not something the model performed.
+ expect(await routing).toEqual({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ tabId: 'tab-auth',
+ tabName: undefined,
+ prompt: 'what happened to auth',
+ confidence: 0.8,
+ });
+ });
+
+ it('sends a tool call naming an agent that is not running to the conductor', async () => {
+ const adapter = await startAdapter();
+
+ const routing = adapter.route('ask ghost something', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ socket.server({
+ type: 'response.function_call_arguments.done',
+ arguments: JSON.stringify({
+ target: { sessionId: 'ghost' },
+ tabAction: 'current',
+ prompt: 'something',
+ confidence: 0.9,
+ }),
+ });
+
+ expect((await routing).target).toBe('conductor');
+ });
+
+ it('hands the turn to the conductor when no tool call arrives', async () => {
+ const adapter = await startAdapter();
+
+ // No tool call at all: the turn must still resolve, or the session hangs
+ // with the user waiting on silence.
+ const decision = await adapter.route('do the thing', {
+ roster: ROSTER,
+ scope: { kind: 'conductor' },
+ });
+
+ expect(decision.target).toBe('conductor');
+ expect(decision.prompt).toBe('do the thing');
+ });
+
+ it('cuts the spoken reply into sentences with their own audio', async () => {
+ const adapter = await startAdapter();
+
+ const conversing = adapter.converse('The migration finished and the tests pass.', {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-auth',
+ });
+
+ socket.server({ type: 'response.audio_transcript.delta', delta: 'Migration done. ' });
+ socket.server({ type: 'response.audio.delta', delta: Buffer.from([1, 2]).toString('base64') });
+ socket.server({ type: 'response.audio_transcript.delta', delta: 'Tests pass.' });
+ socket.server({ type: 'response.audio.delta', delta: Buffer.from([3, 4]).toString('base64') });
+ socket.server({ type: 'response.done' });
+
+ const spoken = await conversing;
+ expect(spoken).toBe('Migration done. Tests pass.');
+
+ const chunks: TtsChunk[] = [];
+ for await (const chunk of adapter.speak(spoken, { utteranceId: 'u1' })) chunks.push(chunk);
+
+ expect(chunks.map((chunk) => chunk.text)).toEqual(['Migration done.', 'Tests pass.']);
+ // Audio, not a re-synthesis: the model already made it.
+ expect(chunks[0].format).toBe('pcm16');
+ expect(chunks[0].audio).toBeInstanceOf(Uint8Array);
+ });
+
+ it('propagates an interruption to the server and drops queued audio', async () => {
+ const adapter = await startAdapter();
+
+ adapter.cancel();
+
+ // Both matter. `response.cancel` stops generation; without the buffer clear
+ // the server keeps streaming what it had already made and the assistant
+ // talks over the interruption.
+ expect(socket.sentOfType('response.cancel')).toHaveLength(1);
+ expect(socket.sentOfType('output_audio_buffer.clear')).toHaveLength(1);
+ });
+
+ it('stops delivering sentences once cancelled', async () => {
+ const adapter = await startAdapter();
+
+ const conversing = adapter.converse('Something happened.', {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-auth',
+ });
+ socket.server({ type: 'response.audio_transcript.delta', delta: 'One. Two. Three.' });
+ socket.server({ type: 'response.done' });
+ const spoken = await conversing;
+
+ const chunks: TtsChunk[] = [];
+ for await (const chunk of adapter.speak(spoken, { utteranceId: 'u1' })) {
+ chunks.push(chunk);
+ adapter.cancel();
+ }
+
+ expect(chunks).toHaveLength(1);
+ });
+
+ it('reports a server error as a classified network failure', async () => {
+ const errors: Error[] = [];
+ await startAdapter({
+ onPartial: () => {},
+ onFinal: () => {},
+ onError: (error) => errors.push(error),
+ });
+
+ socket.server({ type: 'error', error: { message: 'session expired' } });
+
+ expect(errors[0].message).toContain('session expired');
+ });
+
+ it('closes the socket on stop', async () => {
+ const adapter = await startAdapter();
+ await adapter.stop();
+ expect(socket.closed).toBe(true);
+ });
+
+ it('upsamples capture audio to the rate the API speaks', async () => {
+ const adapter = await startAdapter();
+
+ // 320 samples of 16 kHz is 20 ms; at 24 kHz that is 480 samples, so 960
+ // bytes. Sending 16 kHz samples labelled as 24 kHz would make every voice
+ // sound like a chipmunk and every transcript wrong.
+ adapter.feed(new Int16Array(320));
+
+ const appended = socket.sentOfType('input_audio_buffer.append')[0];
+ expect(Buffer.from(String(appended.audio), 'base64').byteLength).toBe(960);
+ });
+});
+
+describe('RealtimePipeline', () => {
+ it('puts one adapter in all three slots', async () => {
+ const adapter = await startAdapter();
+ const pipeline = new RealtimePipeline(adapter);
+
+ expect(pipeline.shape).toBe('realtime');
+ expect(pipeline.providers.stt).toBe(adapter);
+ expect(pipeline.providers.tts).toBe(adapter);
+ expect(pipeline.providers.brain).toBe(adapter);
+ });
+
+ it('closes the session when disposed', async () => {
+ const adapter = await startAdapter();
+ await new RealtimePipeline(adapter).dispose();
+
+ expect(socket.closed).toBe(true);
+ });
+});
diff --git a/src/__tests__/main/acappella/remote-session.test.ts b/src/__tests__/main/acappella/remote-session.test.ts
new file mode 100644
index 0000000000..94b4972669
--- /dev/null
+++ b/src/__tests__/main/acappella/remote-session.test.ts
@@ -0,0 +1,309 @@
+/**
+ * Remote session semantics: what it means for a phone to hold the microphone.
+ *
+ * The claims under test are the ones a user would notice being wrong:
+ *
+ * - a remote utterance takes the IDENTICAL path a local one takes, which here
+ * means it presses the same floor controller and starts an ordinary session
+ * whose only difference is a `remote` origin;
+ * - exactly one device holds the floor, last press wins, and a stale release
+ * from the displaced device cannot shut the new holder's microphone;
+ * - a lost connection ends the session cleanly and stops speech, while an ICE
+ * `disconnected` (a WiFi to LTE handover) does not.
+ *
+ * No peer connection and no audio: the floor is a fake with the same interface
+ * the real `FloorController` presents.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { RemoteSessionCoordinator } from '../../../main/acappella/transport/remote-session';
+import type {
+ RemoteFloor,
+ RemoteMessageSink,
+ RemoteVoiceSession,
+} from '../../../main/acappella/transport/remote-session';
+import type { DeviceMessage } from '../../../shared/acappella/device-protocol';
+import type { VoiceEvent, VoiceOrigin, VoiceScope } from '../../../shared/acappella/protocol';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+/** Every press the coordinator made, with what it was credited to. */
+let presses: Array<{ scope: VoiceScope; origin: VoiceOrigin }>;
+let releases: number;
+let floor: RemoteFloor;
+let session: RemoteVoiceSession & {
+ interrupt: ReturnType;
+ stopSession: ReturnType;
+ hardStop: ReturnType;
+};
+let listeners: Array<(event: VoiceEvent) => void>;
+let sink: RemoteMessageSink & { sent: Array<{ deviceId: string; message: DeviceMessage }> };
+let broadcasts: DeviceMessage[];
+let coordinator: RemoteSessionCoordinator;
+
+function emit(event: Partial & { type: VoiceEvent['type'] }): void {
+ const full = { sessionId: 's1', seq: 1, ts: 0, ...event } as VoiceEvent;
+ for (const listener of listeners) listener(full);
+}
+
+/** Sent messages of one type, for one device. */
+function sentTo(deviceId: string, type: DeviceMessage['type']): DeviceMessage[] {
+ return sink.sent
+ .filter((entry) => entry.deviceId === deviceId && entry.message.type === type)
+ .map((entry) => entry.message);
+}
+
+beforeEach(() => {
+ presses = [];
+ releases = 0;
+ listeners = [];
+ broadcasts = [];
+ floor = {
+ press: vi.fn(async () => {}),
+ release: vi.fn(async () => {
+ releases += 1;
+ }),
+ close: vi.fn(async () => {}),
+ isFloorOpen: false,
+ };
+ session = {
+ subscribe: (listener: (event: VoiceEvent) => void) => {
+ listeners.push(listener);
+ return () => {
+ listeners = listeners.filter((entry) => entry !== listener);
+ };
+ },
+ interrupt: vi.fn(() => true),
+ stopSession: vi.fn(async () => {}),
+ hardStop: vi.fn(async () => {}),
+ getState: () => 'listening',
+ } as unknown as typeof session;
+ sink = {
+ sent: [],
+ send: (deviceId: string, message: DeviceMessage) => sink.sent.push({ deviceId, message }),
+ broadcast: (message: DeviceMessage) => broadcasts.push(message),
+ } as unknown as typeof sink;
+
+ coordinator = new RemoteSessionCoordinator({
+ session,
+ sink,
+ acquireFloor: (scope, origin) => {
+ presses.push({ scope, origin });
+ return floor;
+ },
+ getDeviceName: (deviceId) => `Device ${deviceId}`,
+ });
+});
+
+describe('a remote utterance takes the local path', () => {
+ it('presses the same floor controller a hotkey presses', async () => {
+ coordinator.handleConnected('phone');
+ await coordinator.requestFloor('phone');
+
+ expect(floor.press).toHaveBeenCalledWith('remote-device');
+ expect(presses).toHaveLength(1);
+ });
+
+ it('credits the session to the device without changing anything else', async () => {
+ await coordinator.requestFloor('phone', { kind: 'agent', sessionId: 'agent-7' });
+
+ // The scope, and therefore the routing, dispatch, and TTS behind it, is
+ // exactly what a desktop press with the same scope would produce.
+ expect(presses[0].scope).toEqual({ kind: 'agent', sessionId: 'agent-7' });
+ expect(presses[0].origin).toEqual({
+ kind: 'remote',
+ deviceId: 'phone',
+ deviceName: 'Device phone',
+ });
+ });
+
+ it('defaults to conductor scope', async () => {
+ await coordinator.requestFloor('phone');
+ expect(presses[0].scope).toEqual({ kind: 'conductor' });
+ });
+});
+
+describe('floor takeover', () => {
+ it('gives the floor to the device that pressed last', async () => {
+ coordinator.handleConnected('phone');
+ coordinator.handleConnected('laptop');
+
+ await coordinator.requestFloor('phone');
+ expect(coordinator.floorHolder).toBe('phone');
+
+ await coordinator.requestFloor('laptop');
+ expect(coordinator.floorHolder).toBe('laptop');
+ });
+
+ it('tells the displaced device it lost the floor, and who to', async () => {
+ coordinator.handleConnected('phone');
+ coordinator.handleConnected('laptop');
+ await coordinator.requestFloor('phone');
+ sink.sent.length = 0;
+
+ await coordinator.requestFloor('laptop');
+
+ const takeover = sentTo('phone', 'floor-state').find(
+ (message) => message.type === 'floor-state' && message.takenOverBy
+ );
+ expect(takeover).toMatchObject({ holder: 'laptop', isSelf: false });
+ });
+
+ it('ignores a repeated press from the holder', async () => {
+ await coordinator.requestFloor('phone');
+ await coordinator.requestFloor('phone');
+ expect(floor.press).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores a stale release from a device that already lost the floor', async () => {
+ await coordinator.requestFloor('phone');
+ await coordinator.requestFloor('laptop');
+
+ // The phone's release lands a few milliseconds after the takeover. Acting on
+ // it would shut the microphone of the device that just took the floor.
+ await coordinator.releaseFloor('phone');
+ expect(releases).toBe(0);
+ expect(coordinator.floorHolder).toBe('laptop');
+
+ await coordinator.releaseFloor('laptop');
+ expect(releases).toBe(1);
+ });
+
+ it('hands the floor back to the desktop when a local session starts', async () => {
+ await coordinator.requestFloor('phone');
+ emit({ type: 'listen-start', origin: { kind: 'local' } } as Partial & {
+ type: 'listen-start';
+ });
+ expect(coordinator.floorHolder).toBe('local');
+ });
+
+ it('clears the holder when the session ends', async () => {
+ coordinator.handleConnected('phone');
+ await coordinator.requestFloor('phone');
+ emit({ type: 'listen-stop', reason: 'stopped' } as Partial & {
+ type: 'listen-stop';
+ });
+ expect(coordinator.floorHolder).toBeNull();
+ });
+});
+
+describe('interrupts from a device', () => {
+ it('barges in without ending the session', async () => {
+ await coordinator.requestFloor('phone');
+ coordinator.handleDeviceMessage('phone', { type: 'interrupt', kind: 'barge-in' });
+ expect(session.interrupt).toHaveBeenCalledWith('client-button');
+ expect(session.hardStop).not.toHaveBeenCalled();
+ });
+
+ it('ends the session on a stop word', async () => {
+ await coordinator.requestFloor('phone');
+ coordinator.handleDeviceMessage('phone', { type: 'interrupt', kind: 'stop-word' });
+ expect(session.hardStop).toHaveBeenCalledWith('client-button');
+ });
+
+ it('refuses an interrupt from a device that is not holding the floor', async () => {
+ await coordinator.requestFloor('phone');
+ coordinator.handleDeviceMessage('laptop', { type: 'interrupt', kind: 'barge-in' });
+ expect(session.interrupt).not.toHaveBeenCalled();
+ });
+
+ it('routes a floor message through the same path as a direct request', async () => {
+ coordinator.handleDeviceMessage('phone', { type: 'floor', action: 'press' });
+ await coordinator.whenSettled();
+ expect(coordinator.floorHolder).toBe('phone');
+
+ coordinator.handleDeviceMessage('phone', { type: 'floor', action: 'release' });
+ await coordinator.whenSettled();
+ expect(releases).toBe(1);
+ });
+});
+
+describe('connection loss', () => {
+ it('ends the session cleanly when the holder disappears', async () => {
+ coordinator.handleConnected('phone');
+ await coordinator.requestFloor('phone');
+
+ coordinator.handleDisconnected('phone', 'socket closed');
+ await coordinator.whenSettled();
+
+ // Speech is cancelled before the session is closed: the chunks are already
+ // queued in the audio host and the interrupt is what discards them.
+ expect(session.interrupt).toHaveBeenCalled();
+ expect(session.stopSession).toHaveBeenCalledWith('user');
+ expect(coordinator.floorHolder).toBeNull();
+ });
+
+ it('leaves the session alone when a device that was not holding it drops', async () => {
+ coordinator.handleConnected('phone');
+ coordinator.handleConnected('laptop');
+ await coordinator.requestFloor('phone');
+
+ coordinator.handleDisconnected('laptop', 'socket closed');
+ await coordinator.whenSettled();
+
+ expect(session.stopSession).not.toHaveBeenCalled();
+ expect(coordinator.floorHolder).toBe('phone');
+ });
+
+ it('survives an ICE disconnect, because that is a WiFi to LTE handover', async () => {
+ coordinator.handleConnected('phone');
+ await coordinator.requestFloor('phone');
+
+ coordinator.handlePeerState('phone', 'disconnected');
+ await coordinator.whenSettled();
+
+ expect(session.stopSession).not.toHaveBeenCalled();
+ expect(coordinator.floorHolder).toBe('phone');
+ });
+
+ it.each(['failed', 'closed'] as const)('ends the session on a %s peer', async (state) => {
+ coordinator.handleConnected('phone');
+ await coordinator.requestFloor('phone');
+
+ coordinator.handlePeerState('phone', state);
+ await coordinator.whenSettled();
+
+ expect(session.stopSession).toHaveBeenCalledWith('user');
+ expect(coordinator.floorHolder).toBeNull();
+ });
+});
+
+describe('event fan-out', () => {
+ it('forwards the session stream to connected devices', () => {
+ coordinator.handleConnected('phone');
+ emit({ type: 'partial-transcript', text: 'hello', stability: 0.4 } as Partial & {
+ type: 'partial-transcript';
+ });
+ expect(broadcasts).toContainEqual(
+ expect.objectContaining({
+ type: 'voice-event',
+ event: expect.objectContaining({ type: 'partial-transcript' }),
+ })
+ );
+ });
+
+ it('sends nothing when no device is connected', () => {
+ emit({ type: 'listen-stop', reason: 'stopped' } as Partial & {
+ type: 'listen-stop';
+ });
+ expect(broadcasts).toHaveLength(0);
+ });
+
+ it('keeps every connected device up to date on who holds the floor', async () => {
+ coordinator.handleConnected('phone');
+ coordinator.handleConnected('laptop');
+ sink.sent.length = 0;
+
+ await coordinator.requestFloor('phone');
+
+ expect(sentTo('phone', 'floor-state')).toContainEqual(
+ expect.objectContaining({ holder: 'phone', isSelf: true })
+ );
+ expect(sentTo('laptop', 'floor-state')).toContainEqual(
+ expect.objectContaining({ holder: 'phone', isSelf: false })
+ );
+ });
+});
diff --git a/src/__tests__/main/acappella/route-executor.test.ts b/src/__tests__/main/acappella/route-executor.test.ts
new file mode 100644
index 0000000000..e6c93e3b4c
--- /dev/null
+++ b/src/__tests__/main/acappella/route-executor.test.ts
@@ -0,0 +1,772 @@
+/**
+ * @file route-executor.test.ts
+ *
+ * Unit tests for the dispatch executor: building the agent roster out of the
+ * persisted sessions, and turning each of the three tab actions into the
+ * `remote:*` operations the renderer already implements.
+ *
+ * The renderer round trip is behind `VoiceRendererBridge`, so a fake bridge
+ * drives the whole suite: no Electron window, no store, no timers. The one
+ * exception is the bridge's own section, which asserts the exact channel names
+ * and argument ORDER - a shifted argument there is invisible at the type level
+ * and would silently deliver a response channel into a `force` flag.
+ */
+
+import { describe, it, expect, vi, beforeEach, type MockedFunction } from 'vitest';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+vi.mock('../../../main/utils/safe-send', () => ({
+ isWebContentsAvailable: (win: unknown) => !!win,
+}));
+vi.mock('../../../main/web-server/callbacks/remoteRequest', () => ({
+ requestFromRenderer: vi.fn(),
+}));
+vi.mock('../../../main/stores/getters', () => ({
+ getSessionsStore: vi.fn(),
+}));
+
+import { requestFromRenderer } from '../../../main/web-server/callbacks/remoteRequest';
+import {
+ buildAgentRoster,
+ createRendererVoiceBridge,
+ createVoiceRouteExecutor,
+ type CommandReceipt,
+ type FocusTabResult,
+ type NewTabWithPromptResult,
+ type VoiceRendererBridge,
+} from '../../../main/acappella/dispatch/route-executor';
+import { VoiceDispatchError } from '../../../main/acappella/voice-session-service';
+import type { VoiceScope } from '../../../shared/acappella/protocol';
+import type { RouteDecision } from '../../../shared/acappella/route-decision';
+import type { StoredSession } from '../../../main/stores/types';
+import { createMockSession } from '../../helpers/mockSession';
+import { createMockAITab } from '../../helpers/mockTab';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeSession(overrides: Partial = {}): StoredSession {
+ return createMockSession(overrides as never) as unknown as StoredSession;
+}
+
+/** Two agents: Backend with two tabs, Frontend with one. */
+function makeSessions(): StoredSession[] {
+ return [
+ makeSession({
+ id: 'agent-backend',
+ name: 'Backend',
+ toolType: 'claude-code',
+ cwd: '/repo/api',
+ activeTabId: 'tab-auth',
+ aiTabs: [
+ createMockAITab({
+ id: 'tab-auth',
+ name: 'Auth Refactor',
+ createdAt: 1_000,
+ logs: [{ id: 'l1', timestamp: 4_000, source: 'stdout', text: 'hi' }] as never,
+ }),
+ createMockAITab({ id: 'tab-migrations', name: 'DB Migrations', createdAt: 9_000 }),
+ ],
+ }),
+ makeSession({
+ id: 'agent-frontend',
+ name: 'Frontend',
+ toolType: 'codex',
+ cwd: '/repo/web',
+ activeTabId: 'tab-ui',
+ aiTabs: [createMockAITab({ id: 'tab-ui', name: 'Sidebar', createdAt: 2_000 })],
+ }),
+ ];
+}
+
+type FakeBridge = {
+ [K in keyof VoiceRendererBridge]: MockedFunction;
+};
+
+function makeBridge(overrides: Partial = {}): FakeBridge {
+ return {
+ selectSession: vi.fn(),
+ renameTab: vi.fn(),
+ newTab: vi.fn(async () => 'tab-created'),
+ newTabWithPrompt: vi.fn(
+ async (): Promise => ({ success: true, tabId: 'tab-created' })
+ ),
+ executeCommand: vi.fn(
+ async (): Promise => ({ accepted: true })
+ ),
+ focusTab: vi.fn(
+ async (_agentSessionId, tabId): Promise => ({
+ ok: true,
+ tabId,
+ action: 'focused',
+ })
+ ),
+ ...overrides,
+ };
+}
+
+function makeDecision(overrides: Partial = {}): RouteDecision {
+ return {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'refactor the auth middleware',
+ confidence: 0.8,
+ ...overrides,
+ };
+}
+
+const CONDUCTOR_SCOPE: VoiceScope = { kind: 'conductor' };
+
+/** Bind an executor over a fixed session list. */
+function makeExecutor(options: {
+ bridge: VoiceRendererBridge;
+ sessions?: StoredSession[];
+ activeSessionId?: string | null;
+}) {
+ return createVoiceRouteExecutor({
+ bridge: options.bridge,
+ getSessions: () => options.sessions ?? makeSessions(),
+ getActiveSessionId: () => options.activeSessionId ?? null,
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Roster
+// ---------------------------------------------------------------------------
+
+describe('buildAgentRoster', () => {
+ it('compacts sessions into agents with their AI tabs', () => {
+ const roster = buildAgentRoster(makeSessions());
+
+ expect(roster).toHaveLength(2);
+ expect(roster[0]).toMatchObject({
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ });
+ expect(roster[0].tabs.map((tab) => tab.id)).toEqual(['tab-auth', 'tab-migrations']);
+ expect(roster[1].tabs[0]).toMatchObject({
+ id: 'tab-ui',
+ name: 'Sidebar',
+ lastActiveAt: 2_000,
+ state: 'open',
+ });
+ });
+
+ it('dates a tab by its last log, not just its creation', () => {
+ const [backend] = buildAgentRoster(makeSessions());
+
+ // tab-auth was created at 1000 but last spoke at 4000.
+ expect(backend.tabs[0].lastActiveAt).toBe(4_000);
+ expect(backend.tabs[1].lastActiveAt).toBe(9_000);
+ });
+
+ it('omits hidden consult tabs and reports an unnamed tab as null', () => {
+ const roster = buildAgentRoster([
+ makeSession({
+ id: 'agent-1',
+ aiTabs: [
+ createMockAITab({ id: 'tab-visible', name: null, createdAt: 1 }),
+ createMockAITab({ id: 'tab-consult', name: 'Consult', hidden: true }),
+ ],
+ }),
+ ]);
+
+ expect(roster[0].tabs).toHaveLength(1);
+ expect(roster[0].tabs[0]).toMatchObject({ id: 'tab-visible', name: null, lastActiveAt: 1 });
+ });
+
+ it('survives a session with no tabs at all', () => {
+ const roster = buildAgentRoster([makeSession({ id: 'agent-1', aiTabs: undefined })]);
+
+ expect(roster[0].tabs).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tab actions
+// ---------------------------------------------------------------------------
+
+describe('executeRouteDecision - current', () => {
+ let bridge: FakeBridge;
+
+ beforeEach(() => {
+ bridge = makeBridge();
+ });
+
+ it('focuses the active tab and delivers the prompt', async () => {
+ const execute = makeExecutor({ bridge });
+
+ const result = await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(bridge.selectSession).toHaveBeenCalledWith('agent-backend', 'tab-auth');
+ expect(bridge.executeCommand).toHaveBeenCalledWith(
+ 'agent-backend',
+ 'tab-auth',
+ 'refactor the auth middleware'
+ );
+ expect(result).toEqual({
+ agentSessionId: 'agent-backend',
+ agentName: 'Backend',
+ tabId: 'tab-auth',
+ tabName: 'Auth Refactor',
+ action: 'focused',
+ promptSent: true,
+ });
+ });
+
+ it('falls back to the most recently active tab when activeTabId is stale', async () => {
+ const sessions = makeSessions();
+ sessions[0].activeTabId = 'tab-closed-yesterday';
+ const execute = makeExecutor({ bridge, sessions });
+
+ const result = await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(result.tabId).toBe('tab-migrations');
+ expect(result.action).toBe('focused');
+ });
+
+ it('creates a tab when the agent has none, and says so', async () => {
+ const sessions = [makeSession({ id: 'agent-backend', name: 'Backend', aiTabs: [] })];
+ const execute = makeExecutor({ bridge, sessions });
+
+ const result = await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(bridge.newTabWithPrompt).toHaveBeenCalledWith(
+ 'agent-backend',
+ 'refactor the auth middleware'
+ );
+ expect(result).toMatchObject({ tabId: 'tab-created', action: 'created', promptSent: true });
+ });
+
+ it('treats a rejected delivery receipt as a dispatch failure', async () => {
+ bridge.executeCommand.mockResolvedValue({ accepted: false, reason: 'session-busy' });
+ const execute = makeExecutor({ bridge });
+
+ await expect(execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE })).rejects.toThrow(
+ VoiceDispatchError
+ );
+ });
+});
+
+describe('executeRouteDecision - new', () => {
+ it('opens a tab with the prompt in one operation and names it', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ const result = await execute(makeDecision({ tabAction: 'new', tabName: 'Auth Refactor' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(bridge.newTabWithPrompt).toHaveBeenCalledWith(
+ 'agent-backend',
+ 'refactor the auth middleware'
+ );
+ expect(bridge.renameTab).toHaveBeenCalledWith('agent-backend', 'tab-created', 'Auth Refactor');
+ expect(bridge.executeCommand).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ agentSessionId: 'agent-backend',
+ agentName: 'Backend',
+ tabId: 'tab-created',
+ tabName: 'Auth Refactor',
+ action: 'created',
+ promptSent: true,
+ });
+ });
+
+ it('opens an empty tab without dispatching when there is no prompt', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ const result = await execute(makeDecision({ tabAction: 'new', prompt: ' ' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(bridge.newTab).toHaveBeenCalledWith('agent-backend');
+ expect(bridge.newTabWithPrompt).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ action: 'created', promptSent: false });
+ });
+
+ it('fails the dispatch when the renderer does not create the tab', async () => {
+ const bridge = makeBridge({
+ newTabWithPrompt: vi.fn(async () => ({
+ success: false,
+ })),
+ });
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ tabAction: 'new' }), { roster: [], scope: CONDUCTOR_SCOPE })
+ ).rejects.toThrow(VoiceDispatchError);
+ });
+});
+
+describe('executeRouteDecision - recall', () => {
+ it('returns to the named tab', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ const result = await execute(makeDecision({ tabAction: 'recall', tabId: 'tab-migrations' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ // A REQUEST, not a fire-and-forget send: the renderer is the only side that
+ // can tell a focus from a wake from a reopen.
+ expect(bridge.focusTab).toHaveBeenCalledWith('agent-backend', 'tab-migrations');
+ expect(result).toMatchObject({
+ tabId: 'tab-migrations',
+ tabName: 'DB Migrations',
+ action: 'recalled',
+ promptSent: true,
+ });
+ });
+
+ it('fails rather than guessing when the recalled tab is gone', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ tabAction: 'recall', tabId: 'tab-closed' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ })
+ ).rejects.toThrow(/no longer open/);
+ expect(bridge.focusTab).not.toHaveBeenCalled();
+ expect(bridge.executeCommand).not.toHaveBeenCalled();
+ });
+
+ it('fails when a recall arrives with no tab id', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ tabAction: 'recall' }), { roster: [], scope: CONDUCTOR_SCOPE })
+ ).rejects.toThrow(VoiceDispatchError);
+ });
+
+ it('lands on the tab the renderer actually landed on', async () => {
+ // Waking a snooze whose conversation is already open focuses the copy that
+ // exists rather than restoring a duplicate.
+ const bridge = makeBridge({
+ focusTab: vi.fn(async () => ({
+ ok: true,
+ tabId: 'tab-auth',
+ action: 'woke',
+ })),
+ });
+ const execute = makeExecutor({ bridge });
+
+ const result = await execute(makeDecision({ tabAction: 'recall', tabId: 'tab-migrations' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(result.tabId).toBe('tab-auth');
+ expect(bridge.executeCommand).toHaveBeenCalledWith(
+ 'agent-backend',
+ 'tab-auth',
+ 'refactor the auth middleware'
+ );
+ });
+
+ it('fails rather than announcing a recall the renderer did not perform', async () => {
+ const bridge = makeBridge({
+ focusTab: vi.fn(async () => ({
+ ok: false,
+ reason: 'renderer-timeout',
+ })),
+ });
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ tabAction: 'recall', tabId: 'tab-migrations' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ })
+ ).rejects.toThrow(/renderer-timeout/);
+ expect(bridge.executeCommand).not.toHaveBeenCalled();
+ });
+
+ it('wakes a snoozed tab rather than treating it as gone', async () => {
+ const sessions = makeSessions();
+ sessions[0].snoozedTabs = [
+ {
+ id: 'snooze-1',
+ tab: createMockAITab({ id: 'tab-spike', name: 'Rate Limit Spike', createdAt: 5 }),
+ unifiedIndex: 0,
+ snoozedAt: 1,
+ wakeAt: 999_999,
+ },
+ ];
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, sessions });
+
+ const result = await execute(makeDecision({ tabAction: 'recall', tabId: 'tab-spike' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(bridge.focusTab).toHaveBeenCalledWith('agent-backend', 'tab-spike');
+ expect(result.action).toBe('recalled');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Tab state
+// ---------------------------------------------------------------------------
+
+describe('executeRouteDecision - current', () => {
+ it('never treats a snoozed or closed tab as the current one', async () => {
+ // The roster lists them so recall can name them. "Carry on where we were"
+ // landing on a tab the user put away last week would be the worst possible
+ // reading of "current".
+ const sessions = [
+ makeSession({
+ id: 'agent-backend',
+ name: 'Backend',
+ activeTabId: null,
+ aiTabs: [createMockAITab({ id: 'tab-open', name: 'Open', createdAt: 1 })],
+ snoozedTabs: [
+ {
+ id: 'snooze-1',
+ tab: createMockAITab({ id: 'tab-snoozed', name: 'Snoozed', createdAt: 9_000 }),
+ unifiedIndex: 0,
+ snoozedAt: 1,
+ wakeAt: 999_999,
+ },
+ ],
+ }),
+ ];
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, sessions });
+
+ const result = await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(result.tabId).toBe('tab-open');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Idempotency
+// ---------------------------------------------------------------------------
+
+describe('executeRouteDecision - idempotency', () => {
+ it('replays an identical decision instead of opening a second tab', async () => {
+ const bridge = makeBridge();
+ const execute = createVoiceRouteExecutor({
+ bridge,
+ getSessions: () => makeSessions(),
+ getActiveSessionId: () => null,
+ });
+ const decision = makeDecision({ tabAction: 'new', tabName: 'Auth Refactor' });
+
+ const first = await execute(decision, { roster: [], scope: CONDUCTOR_SCOPE });
+ const second = await execute(decision, { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(bridge.newTabWithPrompt).toHaveBeenCalledTimes(1);
+ expect(second).toEqual(first);
+ });
+
+ it('treats a different prompt to the same tab as a new dispatch', async () => {
+ const bridge = makeBridge();
+ const execute = createVoiceRouteExecutor({
+ bridge,
+ getSessions: () => makeSessions(),
+ getActiveSessionId: () => null,
+ });
+
+ await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+ await execute(makeDecision({ prompt: 'and run the linter' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(bridge.executeCommand).toHaveBeenCalledTimes(2);
+ });
+
+ it('lets the replay window expire, because a repeat later is intent', async () => {
+ const bridge = makeBridge();
+ const execute = createVoiceRouteExecutor({
+ bridge,
+ getSessions: () => makeSessions(),
+ getActiveSessionId: () => null,
+ replayWindowMs: 0,
+ });
+
+ await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+ await execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE });
+
+ expect(bridge.executeCommand).toHaveBeenCalledTimes(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Clarifications
+// ---------------------------------------------------------------------------
+
+describe('executeRouteDecision - clarifications', () => {
+ it('refuses to dispatch a question', async () => {
+ // Reaching the executor with one means a caller skipped the guard, and
+ // dispatching it would send the user their own half-finished request.
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ clarify: 'Backend or Frontend?' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ })
+ ).rejects.toThrow(/question, not a dispatch/);
+ expect(bridge.executeCommand).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Target resolution
+// ---------------------------------------------------------------------------
+
+describe('executeRouteDecision - target resolution', () => {
+ it('fails when the targeted agent closed while the decision was in flight', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, sessions: [] });
+
+ await expect(execute(makeDecision(), { roster: [], scope: CONDUCTOR_SCOPE })).rejects.toThrow(
+ /no longer running/
+ );
+ });
+
+ it('sends a conductor decision to the session scope agent', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, activeSessionId: 'agent-backend' });
+
+ const result = await execute(makeDecision({ target: 'conductor' }), {
+ roster: [],
+ scope: { kind: 'agent', sessionId: 'agent-frontend' },
+ });
+
+ // The bound scope outranks whichever agent the desktop happens to show.
+ expect(result.agentSessionId).toBe('agent-frontend');
+ });
+
+ it('falls back to the active desktop agent for an unscoped conductor decision', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, activeSessionId: 'agent-frontend' });
+
+ const result = await execute(makeDecision({ target: 'conductor' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(result.agentSessionId).toBe('agent-frontend');
+ });
+
+ it('uses the only agent there is', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, sessions: [makeSessions()[1]] });
+
+ const result = await execute(makeDecision({ target: 'conductor' }), {
+ roster: [],
+ scope: CONDUCTOR_SCOPE,
+ });
+
+ expect(result.agentSessionId).toBe('agent-frontend');
+ });
+
+ it('refuses to guess between several agents with nothing active', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge });
+
+ await expect(
+ execute(makeDecision({ target: 'conductor' }), { roster: [], scope: CONDUCTOR_SCOPE })
+ ).rejects.toThrow(VoiceDispatchError);
+ expect(bridge.executeCommand).not.toHaveBeenCalled();
+ });
+
+ it('reports having no agents at all distinctly', async () => {
+ const bridge = makeBridge();
+ const execute = makeExecutor({ bridge, sessions: [] });
+
+ await expect(
+ execute(makeDecision({ target: 'conductor' }), { roster: [], scope: CONDUCTOR_SCOPE })
+ ).rejects.toThrow(/No agents are open/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Renderer bridge
+// ---------------------------------------------------------------------------
+
+describe('createRendererVoiceBridge', () => {
+ const send = vi.fn();
+ const show = vi.fn();
+ const win = {
+ webContents: { send },
+ isMinimized: () => false,
+ restore: vi.fn(),
+ show,
+ } as never;
+
+ beforeEach(() => {
+ send.mockClear();
+ vi.mocked(requestFromRenderer).mockReset();
+ });
+
+ it('focuses and renames over the existing remote channels', () => {
+ const bridge = createRendererVoiceBridge(() => win);
+
+ bridge.selectSession('agent-1', 'tab-1');
+ bridge.renameTab('agent-1', 'tab-1', 'Auth Refactor');
+
+ expect(send).toHaveBeenNthCalledWith(1, 'remote:selectSession', 'agent-1', 'tab-1');
+ expect(send).toHaveBeenNthCalledWith(
+ 2,
+ 'remote:renameTab',
+ 'agent-1',
+ 'tab-1',
+ 'Auth Refactor'
+ );
+ });
+
+ it('creates a tab with a prompt atomically, leaving room for the response channel', async () => {
+ vi.mocked(requestFromRenderer).mockResolvedValue({ success: true, tabId: 'tab-9' });
+ const bridge = createRendererVoiceBridge(() => win);
+
+ const result = await bridge.newTabWithPrompt('agent-1', 'do the thing');
+
+ expect(result).toEqual({ success: true, tabId: 'tab-9' });
+ const [, channel, options] = vi.mocked(requestFromRenderer).mock.calls[0];
+ expect(channel).toBe('remote:newAITabWithPrompt');
+ // `background` is deliberately omitted so the response channel lands in
+ // its slot AND the new tab takes focus.
+ expect(options.args).toEqual(['agent-1', 'do the thing']);
+ });
+
+ it('sends a prompt with the full positional argument list', async () => {
+ vi.mocked(requestFromRenderer).mockResolvedValue({ accepted: true });
+ const bridge = createRendererVoiceBridge(() => win);
+
+ await bridge.executeCommand('agent-1', 'tab-1', 'do the thing');
+
+ const [, channel, options] = vi.mocked(requestFromRenderer).mock.calls[0];
+ expect(channel).toBe('remote:executeCommand');
+ expect(options.args).toEqual([
+ 'agent-1',
+ 'do the thing',
+ 'ai',
+ 'tab-1',
+ false,
+ undefined,
+ false,
+ ]);
+ });
+
+ it('reads the tab id out of a new-tab reply', async () => {
+ vi.mocked(requestFromRenderer).mockResolvedValue({ tabId: 'tab-3' });
+ const bridge = createRendererVoiceBridge(() => win);
+
+ await expect(bridge.newTab('agent-1')).resolves.toBe('tab-3');
+ });
+
+ it('classifies a missing renderer as a dispatch failure', () => {
+ const bridge = createRendererVoiceBridge(() => null);
+
+ expect(() => bridge.selectSession('agent-1')).toThrow(VoiceDispatchError);
+ });
+
+ it('asks the renderer to focus a tab and reads back what that took', async () => {
+ vi.mocked(requestFromRenderer).mockResolvedValue({
+ ok: true,
+ tabId: 'tab-1',
+ action: 'woke',
+ });
+ const bridge = createRendererVoiceBridge(() => win);
+
+ const result = await bridge.focusTab('agent-1', 'tab-1');
+
+ const [, channel, options] = vi.mocked(requestFromRenderer).mock.calls[0];
+ expect(channel).toBe('remote:focusAiTab');
+ expect(options.args).toEqual(['agent-1', 'tab-1']);
+ expect(result).toEqual({ ok: true, tabId: 'tab-1', action: 'woke' });
+ });
+
+ it('treats a malformed focus reply as a failure rather than a success', async () => {
+ vi.mocked(requestFromRenderer).mockResolvedValue({ ok: true });
+ const bridge = createRendererVoiceBridge(() => win);
+
+ await bridge.focusTab('agent-1', 'tab-1');
+
+ // The parser runs inside `requestFromRenderer`, which is mocked here, so it
+ // is exercised directly: a truthy-looking reply must not become an `ok`.
+ const [, , options] = vi.mocked(requestFromRenderer).mock.calls[0];
+ expect(options.parse!({ ok: 'yes' })).toMatchObject({ ok: false });
+ expect(options.parse!('sure')).toMatchObject({ ok: false, reason: 'malformed-result' });
+ expect(options.fallback).toMatchObject({ ok: false, reason: 'renderer-timeout' });
+ });
+
+ // -- Multi-window --------------------------------------------------------
+
+ describe('multi-window', () => {
+ function makeWindow(label: string) {
+ return {
+ label,
+ webContents: { send: vi.fn() },
+ isMinimized: () => false,
+ restore: vi.fn(),
+ show: vi.fn(),
+ };
+ }
+
+ it('dispatches into the window that owns the agent, not whichever is main', () => {
+ // Agent ownership is per window while `activeSessionId` is global, so
+ // dispatching to "main" would activate an agent that window does not own.
+ const main = makeWindow('main');
+ const secondary = makeWindow('secondary');
+ const bridge = createRendererVoiceBridge(
+ () => main as never,
+ (sessionId) => (sessionId === 'agent-2' ? (secondary as never) : null)
+ );
+
+ bridge.selectSession('agent-2', 'tab-1');
+
+ expect(secondary.webContents.send).toHaveBeenCalledWith(
+ 'remote:selectSession',
+ 'agent-2',
+ 'tab-1'
+ );
+ expect(main.webContents.send).not.toHaveBeenCalled();
+ });
+
+ it('raises the owning window: a dispatch behind another window did nothing', () => {
+ const secondary = makeWindow('secondary');
+ secondary.isMinimized = () => true;
+ const bridge = createRendererVoiceBridge(
+ () => null,
+ () => secondary as never
+ );
+
+ bridge.selectSession('agent-2', 'tab-1');
+
+ expect(secondary.restore).toHaveBeenCalled();
+ expect(secondary.show).toHaveBeenCalled();
+ });
+
+ it('falls back to the main window when no window claims the agent', () => {
+ const main = makeWindow('main');
+ const bridge = createRendererVoiceBridge(
+ () => main as never,
+ () => null
+ );
+
+ bridge.selectSession('agent-2', 'tab-1');
+
+ expect(main.webContents.send).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/__tests__/main/acappella/router/conductor-agent.test.ts b/src/__tests__/main/acappella/router/conductor-agent.test.ts
new file mode 100644
index 0000000000..6bf02027be
--- /dev/null
+++ b/src/__tests__/main/acappella/router/conductor-agent.test.ts
@@ -0,0 +1,267 @@
+/**
+ * @file conductor-agent.test.ts
+ *
+ * The Conductor run as a real agent: the busy refusal, the deadline, and the
+ * SSH rule that a configured remote which cannot be resolved is a loud failure
+ * rather than a quiet local run.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+vi.mock('../../../../main/utils/ssh-spawn-wrapper', () => ({ wrapSpawnWithSsh: vi.fn() }));
+vi.mock('../../../../main/prompt-manager', () => ({
+ getPrompt: () => {
+ throw new Error('prompts not initialised');
+ },
+}));
+
+import { wrapSpawnWithSsh } from '../../../../main/utils/ssh-spawn-wrapper';
+import {
+ createConductorAgentBrain,
+ type ConductorProcessManager,
+} from '../../../../main/acappella/router/conductor-agent';
+import { VoiceProviderError } from '../../../../shared/acappella/provider-errors';
+import type { VoiceRouteContext } from '../../../../shared/acappella/providers';
+
+const CONTEXT: VoiceRouteContext = {
+ roster: [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [{ id: 'tab-auth', name: 'Auth', lastActiveAt: 1 }],
+ },
+ ],
+ scope: { kind: 'conductor' },
+};
+
+const DECISION_JSON = JSON.stringify({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'run the tests',
+ confidence: 0.9,
+});
+
+/** A process manager that replays one scripted response per spawn. */
+function fakeProcessManager(script: { output?: string; exits?: boolean; spawns?: boolean } = {}) {
+ const handlers = new Map void>>();
+ const spawned: Array> = [];
+ const killed: string[] = [];
+
+ const emit = (event: string, ...args: unknown[]): void => {
+ for (const handler of handlers.get(event) ?? []) handler(...args);
+ };
+
+ const manager: ConductorProcessManager = {
+ spawn(config) {
+ spawned.push(config);
+ if (script.spawns === false) return null;
+ // Asynchronous, like the real thing: the collector must have attached
+ // its listeners before anything arrives.
+ setTimeout(() => {
+ if (script.output) emit('data', config.sessionId, script.output);
+ if (script.exits !== false) emit('exit', config.sessionId, 0);
+ }, 0);
+ return { pid: 1234 };
+ },
+ on(event, handler) {
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
+ },
+ off(event, handler) {
+ handlers.set(
+ event,
+ (handlers.get(event) ?? []).filter((entry) => entry !== handler)
+ );
+ },
+ kill(sessionId) {
+ killed.push(sessionId);
+ },
+ };
+
+ return { manager, spawned, killed, listenerCount: () => (handlers.get('data') ?? []).length };
+}
+
+function agentDetector(available = true) {
+ return {
+ getAgent: vi.fn(async () => ({
+ id: 'claude-code',
+ command: 'claude',
+ binaryName: 'claude',
+ args: ['--print'],
+ available,
+ promptArgs: undefined,
+ noPromptSeparator: false,
+ })),
+ } as never;
+}
+
+function makeBrain(
+ script: Parameters[0] = { output: DECISION_JSON },
+ overrides: Record = {}
+) {
+ const process = fakeProcessManager(script);
+ const brain = createConductorAgentBrain({
+ processManager: process.manager,
+ agentDetector: agentDetector(),
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ timeoutMs: 50,
+ ...overrides,
+ });
+ return { brain, ...process };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('ConductorAgentBrain - routing', () => {
+ it('spawns read-only and parses the decision through the shared schema', async () => {
+ const { brain, spawned } = makeBrain();
+
+ const decision = await brain.route('run the tests', CONTEXT);
+
+ expect(decision).toMatchObject({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ confidence: 0.9,
+ });
+ // A router reads; it does not edit, and read-only also means no workspace
+ // lock, so the Conductor can think while its agents work.
+ expect(spawned[0]).toMatchObject({ readOnlyMode: true, toolType: 'claude-code' });
+ });
+
+ it('validates a hallucinated id away rather than returning it', async () => {
+ const { brain } = makeBrain({
+ output: JSON.stringify({
+ target: { sessionId: 'agent-ghost' },
+ tabAction: 'current',
+ prompt: 'x',
+ confidence: 0.9,
+ }),
+ });
+
+ const decision = await brain.route('run the tests', CONTEXT);
+
+ expect(decision.target).toBe('conductor');
+ });
+
+ it('drops its listeners once the run is over', async () => {
+ const { brain, listenerCount } = makeBrain();
+
+ await brain.route('run the tests', CONTEXT);
+
+ expect(listenerCount()).toBe(0);
+ });
+});
+
+describe('ConductorAgentBrain - never blocks the floor', () => {
+ it('refuses a second turn while one is in flight, out loud', async () => {
+ const { brain } = makeBrain();
+
+ const first = brain.route('run the tests', CONTEXT);
+ const second = brain.route('and the linter', CONTEXT);
+
+ await expect(second).rejects.toThrow(/Conductor is busy/);
+ await first;
+ // The refusal is recoverable: wait, then say it again.
+ await expect(brain.route('and the linter', CONTEXT)).resolves.toBeTruthy();
+ });
+
+ it('kills the process when the deadline passes', async () => {
+ const { brain, killed } = makeBrain({ exits: false });
+
+ await expect(brain.route('run the tests', CONTEXT)).rejects.toThrow(/did not answer in time/);
+ // An abandoned agent left running still holds a model and a token budget.
+ expect(killed).toHaveLength(1);
+ });
+
+ it('reports a spawn that never started', async () => {
+ const { brain } = makeBrain({ spawns: false });
+
+ await expect(brain.route('run the tests', CONTEXT)).rejects.toThrow(/could not be started/);
+ });
+
+ it('reports an unavailable agent as a provider failure', async () => {
+ const { manager } = fakeProcessManager();
+ const brain = createConductorAgentBrain({
+ processManager: manager,
+ agentDetector: agentDetector(false),
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ });
+
+ await expect(brain.route('run the tests', CONTEXT)).rejects.toThrow(VoiceProviderError);
+ });
+});
+
+describe('ConductorAgentBrain - SSH', () => {
+ it('wraps the spawn when a remote is configured', async () => {
+ vi.mocked(wrapSpawnWithSsh).mockResolvedValue({
+ command: 'ssh',
+ args: ['host', 'claude'],
+ cwd: '/remote/api',
+ prompt: 'hello',
+ customEnvVars: {},
+ sshRemoteUsed: { id: 'remote-1', name: 'box' } as never,
+ } as never);
+
+ const { brain, spawned } = makeBrain(
+ { output: DECISION_JSON },
+ {
+ sshRemoteConfig: { enabled: true, remoteId: 'remote-1' },
+ sshStore: {} as never,
+ }
+ );
+
+ await brain.route('run the tests', CONTEXT);
+
+ expect(wrapSpawnWithSsh).toHaveBeenCalled();
+ expect(spawned[0]).toMatchObject({
+ command: 'ssh',
+ cwd: '/remote/api',
+ sshRemoteId: 'remote-1',
+ });
+ });
+
+ it('fails loudly when the remote cannot be resolved, rather than running locally', async () => {
+ vi.mocked(wrapSpawnWithSsh).mockResolvedValue({
+ command: 'claude',
+ args: [],
+ cwd: '/repo/api',
+ sshRemoteUsed: null,
+ } as never);
+
+ const { brain, spawned } = makeBrain(
+ { output: DECISION_JSON },
+ { sshRemoteConfig: { enabled: true, remoteId: 'gone' }, sshStore: {} as never }
+ );
+
+ await expect(brain.route('run the tests', CONTEXT)).rejects.toThrow(/could not be resolved/);
+ // The user opted into a remote. A routing prompt carries the names and paths
+ // of everything they have open, so it must not run here instead.
+ expect(spawned).toHaveLength(0);
+ });
+
+ it('fails loudly when SSH is enabled with no store to resolve it', async () => {
+ const { brain, spawned } = makeBrain(
+ { output: DECISION_JSON },
+ { sshRemoteConfig: { enabled: true, remoteId: 'remote-1' } }
+ );
+
+ await expect(brain.route('run the tests', CONTEXT)).rejects.toThrow(/could not be resolved/);
+ expect(spawned).toHaveLength(0);
+ });
+
+ it('does not touch the SSH wrapper when no remote is configured', async () => {
+ const { brain } = makeBrain();
+
+ await brain.route('run the tests', CONTEXT);
+
+ expect(wrapSpawnWithSsh).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/main/acappella/router/conductor-router.test.ts b/src/__tests__/main/acappella/router/conductor-router.test.ts
new file mode 100644
index 0000000000..6767602fa8
--- /dev/null
+++ b/src/__tests__/main/acappella/router/conductor-router.test.ts
@@ -0,0 +1,421 @@
+/**
+ * @file conductor-router.test.ts
+ *
+ * The decision layer, driven by a fake Brain.
+ *
+ * Every case here is a fixture pair - an utterance and a roster - because that
+ * is the only honest way to test routing: the rules are about what a decision
+ * means against a specific set of agents, and a suite that asserted "the Brain
+ * was called" would pass for a router that dispatched everything to the first
+ * agent in the list.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import {
+ createConductorRouter,
+ isCorrectionUtterance,
+ planCorrection,
+} from '../../../../main/acappella/router/conductor-router';
+import type { RoutingContext } from '../../../../main/acappella/router/routing-context';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+import type { BrainProvider, VoiceRouteContext } from '../../../../shared/acappella/providers';
+import type { RouteDecision } from '../../../../shared/acappella/route-decision';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const NOW = 1_000_000_000;
+
+function roster(): RosterAgent[] {
+ return [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ status: 'idle',
+ tabs: [
+ {
+ id: 'tab-auth',
+ name: 'Auth Refactor',
+ lastActiveAt: NOW - 1000,
+ state: 'open',
+ topic: 'rewrite the auth middleware',
+ },
+ { id: 'tab-db', name: 'DB Migrations', lastActiveAt: NOW, state: 'open', topic: null },
+ ],
+ },
+ {
+ sessionId: 'agent-api',
+ name: 'API',
+ agentType: 'codex',
+ cwd: '/repo/gateway',
+ status: 'idle',
+ tabs: [{ id: 'tab-gw', name: 'Gateway', lastActiveAt: NOW, state: 'open', topic: null }],
+ },
+ ];
+}
+
+function context(overrides: Partial = {}): VoiceRouteContext {
+ return {
+ roster: roster(),
+ scope: { kind: 'conductor' },
+ activeAgentSessionId: null,
+ recentUtterances: [],
+ ...overrides,
+ };
+}
+
+function routingContext(agents: RosterAgent[] = roster()): RoutingContext {
+ return {
+ agents,
+ activeAgentSessionId: null,
+ recentUtterances: [],
+ droppedTabs: 0,
+ serializedChars: 400,
+ };
+}
+
+function decision(overrides: Partial = {}): RouteDecision {
+ return {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'run the tests',
+ confidence: 0.9,
+ ...overrides,
+ };
+}
+
+/** A Brain that returns a scripted decision per call. */
+function fakeBrain(...decisions: RouteDecision[]): BrainProvider & { calls: VoiceRouteContext[] } {
+ const calls: VoiceRouteContext[] = [];
+ let index = 0;
+ return {
+ id: 'fake-brain',
+ label: 'Fake',
+ tier: 'mock',
+ calls,
+ async route(_input, ctx) {
+ calls.push(ctx);
+ return decisions[Math.min(index++, decisions.length - 1)];
+ },
+ async converse(text) {
+ return text;
+ },
+ };
+}
+
+function makeRouter(
+ brain: BrainProvider,
+ options: { agents?: RosterAgent[]; threshold?: number } = {}
+) {
+ const recorded: Array> = [];
+ const router = createConductorRouter({
+ brain,
+ confidenceThreshold: options.threshold,
+ loadContext: async () => routingContext(options.agents ?? roster()),
+ record: (entry) => {
+ recorded.push(entry as unknown as Record);
+ return entry.id;
+ },
+ now: () => NOW,
+ });
+ return { router, recorded };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// The four shapes of a decision
+// ---------------------------------------------------------------------------
+
+describe('createConductorRouter - decisions', () => {
+ it('passes a same-topic continuation through as current', async () => {
+ const { router } = makeRouter(fakeBrain(decision()));
+
+ const result = await router.route('run the tests', context());
+
+ expect(result).toMatchObject({
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'run the tests',
+ });
+ expect(result.clarify).toBeUndefined();
+ });
+
+ it('passes a topic switch through as a new named tab', async () => {
+ const { router } = makeRouter(
+ fakeBrain(decision({ tabAction: 'new', tabName: 'Rate Limiting', confidence: 0.8 }))
+ );
+
+ const result = await router.route('add a rate limiter', context());
+
+ expect(result).toMatchObject({ tabAction: 'new', tabName: 'Rate Limiting' });
+ });
+
+ it('passes a recall of an open tab through untouched', async () => {
+ const { router } = makeRouter(
+ fakeBrain(decision({ tabAction: 'recall', tabId: 'tab-auth', confidence: 0.8 }))
+ );
+
+ const result = await router.route('back to the auth thing', context());
+
+ expect(result).toMatchObject({ tabAction: 'recall', tabId: 'tab-auth' });
+ expect(result.clarify).toBeUndefined();
+ });
+
+ it('passes a conductor-targeted question through', async () => {
+ const { router } = makeRouter(
+ fakeBrain(
+ decision({ target: 'conductor', prompt: 'how many agents are running', confidence: 0.9 })
+ )
+ );
+
+ const result = await router.route('how many agents are running', context());
+
+ expect(result.target).toBe('conductor');
+ });
+
+ it('reshapes the roster it hands the Brain into the shortlist', async () => {
+ const brain = fakeBrain(decision());
+ const { router } = makeRouter(brain);
+
+ await router.route('back to the auth thing', context());
+
+ // Every agent survives - dropping one makes it unroutable - and the enriched
+ // roster from the assembler is what the Brain sees, not the caller's.
+ expect(brain.calls[0].roster.map((agent) => agent.sessionId)).toEqual([
+ 'agent-backend',
+ 'agent-api',
+ ]);
+ expect(brain.calls[0].roster[0].tabs[0].topic).toBe('rewrite the auth middleware');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Low confidence
+// ---------------------------------------------------------------------------
+
+describe('createConductorRouter - disambiguation', () => {
+ it('asks rather than dispatching below the threshold', async () => {
+ const { router } = makeRouter(fakeBrain(decision({ confidence: 0.3 })));
+
+ const result = await router.route('run it', context());
+
+ expect(result.clarify).toBe('Backend or API?');
+ });
+
+ it('names the two best tabs when the low-confidence decision is a recall', async () => {
+ const { router } = makeRouter(
+ fakeBrain(decision({ tabAction: 'recall', tabId: 'tab-auth', confidence: 0.3 }))
+ );
+
+ const result = await router.route('back to that auth gateway thing', context());
+
+ expect(result.clarify).toMatch(/Auth Refactor|Gateway/);
+ });
+
+ it('dispatches a low-confidence decision when there is only one agent', async () => {
+ // Asking "Backend?" of someone who has one agent is worse than acting.
+ const only = [roster()[0]];
+ const { router } = makeRouter(fakeBrain(decision({ confidence: 0.2 })), { agents: only });
+
+ const result = await router.route('run it', context({ roster: only }));
+
+ expect(result.clarify).toBeUndefined();
+ });
+
+ it('honours a threshold override', async () => {
+ const { router } = makeRouter(fakeBrain(decision({ confidence: 0.6 })), { threshold: 0.9 });
+
+ const result = await router.route('run it', context());
+
+ expect(result.clarify).toBe('Backend or API?');
+ });
+
+ it('leaves a question the Brain asked for itself alone', async () => {
+ const { router } = makeRouter(
+ fakeBrain(decision({ confidence: 0.2, clarify: 'the payments one or the gateway?' }))
+ );
+
+ const result = await router.route('do the thing', context());
+
+ expect(result.clarify).toBe('the payments one or the gateway?');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Recovery
+// ---------------------------------------------------------------------------
+
+describe('createConductorRouter - validation failures', () => {
+ it('retries once, telling the model what was wrong', async () => {
+ const brain = fakeBrain(decision({ target: { sessionId: 'agent-ghost' } }), decision());
+ const { router, recorded } = makeRouter(brain);
+
+ const result = await router.route('run the tests', context());
+
+ expect(brain.calls).toHaveLength(2);
+ expect(brain.calls[1].retryNotes?.join(' ')).toContain('decision.target');
+ expect(result.target).toEqual({ sessionId: 'agent-backend' });
+ expect(recorded[0].retries).toBe(1);
+ });
+
+ it('asks out loud rather than guessing after a second rejection', async () => {
+ const bad = decision({ target: { sessionId: 'agent-ghost' } });
+ const { router } = makeRouter(fakeBrain(bad, bad));
+
+ const result = await router.route('deploy the thing', context());
+
+ expect(result.target).toBe('conductor');
+ expect(result.clarify).toContain('Backend or API?');
+ // The user's own words survive, so answering the question routes the
+ // request they actually made.
+ expect(result.prompt).toBe('deploy the thing');
+ });
+
+ it('never dispatches a decision that failed validation twice', async () => {
+ const bad = decision({ tabAction: 'recall', tabId: 'tab-ghost' });
+ const { router } = makeRouter(fakeBrain(bad, bad));
+
+ const result = await router.route('back to that thing', context());
+
+ expect(result.clarify).toBeTruthy();
+ });
+
+ it('routes on the caller roster when the context assembler is unavailable', async () => {
+ const brain = fakeBrain(decision());
+ const router = createConductorRouter({
+ brain,
+ loadContext: async () => {
+ throw new Error('no store');
+ },
+ record: (entry) => entry.id,
+ });
+
+ const result = await router.route('run the tests', context());
+
+ expect(result.target).toEqual({ sessionId: 'agent-backend' });
+ expect(brain.calls[0].roster).toHaveLength(2);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Closed tabs
+// ---------------------------------------------------------------------------
+
+describe('createConductorRouter - closed tab recall', () => {
+ function withClosedAuthTab(): RosterAgent[] {
+ const agents = roster();
+ agents[0].tabs[0] = { ...agents[0].tabs[0], state: 'closed' };
+ return agents;
+ }
+
+ it('offers to reopen rather than silently creating a duplicate', async () => {
+ const agents = withClosedAuthTab();
+ const { router } = makeRouter(
+ fakeBrain(decision({ tabAction: 'recall', tabId: 'tab-auth', confidence: 0.9 })),
+ { agents }
+ );
+
+ const result = await router.route('back to the auth thing', context({ roster: agents }));
+
+ expect(result.clarify).toContain('reopen');
+ expect(result.tabAction).toBe('recall');
+ });
+
+ it('acts on the second pass, once the offer has been answered', async () => {
+ const agents = withClosedAuthTab();
+ const { router } = makeRouter(
+ fakeBrain(decision({ tabAction: 'recall', tabId: 'tab-auth', confidence: 0.9 })),
+ { agents }
+ );
+
+ const result = await router.route(
+ 'yes',
+ context({
+ roster: agents,
+ clarification: { question: 'Reopen it?', utterance: 'back to the auth thing' },
+ })
+ );
+
+ expect(result.clarify).toBeUndefined();
+ expect(result.tabId).toBe('tab-auth');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Logging
+// ---------------------------------------------------------------------------
+
+describe('createConductorRouter - routing log', () => {
+ it('records the turn with the context size and the latency', async () => {
+ const { router, recorded } = makeRouter(fakeBrain(decision()));
+
+ await router.route('run the tests', context());
+
+ expect(recorded[0]).toMatchObject({
+ utterance: 'run the tests',
+ brainProviderId: 'fake-brain',
+ contextChars: 400,
+ droppedTabs: 0,
+ latencyMs: 0,
+ });
+ expect(router.lastTurnId()).toBe(recorded[0].id);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Correction
+// ---------------------------------------------------------------------------
+
+describe('isCorrectionUtterance', () => {
+ it('recognises the short interjections', () => {
+ for (const phrase of ['no, the other one', 'Wrong tab.', 'not that one', 'the other one']) {
+ expect(isCorrectionUtterance(phrase)).toBe(true);
+ }
+ });
+
+ it('does not treat a sentence containing one as a correction', () => {
+ // A false positive silently moves a prompt the user never asked to move.
+ expect(isCorrectionUtterance('no, not that one, use the other endpoint')).toBe(false);
+ expect(isCorrectionUtterance('the other one is failing its tests')).toBe(false);
+ });
+});
+
+describe('planCorrection', () => {
+ it('moves to the only alternative without asking', () => {
+ expect(planCorrection(roster(), 'agent-backend')).toEqual({
+ kind: 'move',
+ agentSessionId: 'agent-api',
+ });
+ });
+
+ it('asks when there are several alternatives', () => {
+ const three = [
+ ...roster(),
+ { sessionId: 'agent-web', name: 'Web', agentType: 'codex', cwd: '/repo/web', tabs: [] },
+ ];
+
+ const plan = planCorrection(three, 'agent-backend');
+
+ expect(plan.kind).toBe('ask');
+ if (plan.kind !== 'ask') throw new Error('expected a question');
+ expect(plan.question).toBe('API, or Web?');
+ });
+
+ it('says so when there is nowhere else to send it', () => {
+ expect(planCorrection([roster()[0]], 'agent-backend')).toEqual({
+ kind: 'ask',
+ question: 'There is nowhere else to send that.',
+ });
+ });
+});
diff --git a/src/__tests__/main/acappella/router/conversation-buffer.test.ts b/src/__tests__/main/acappella/router/conversation-buffer.test.ts
new file mode 100644
index 0000000000..26fed7619b
--- /dev/null
+++ b/src/__tests__/main/acappella/router/conversation-buffer.test.ts
@@ -0,0 +1,98 @@
+/**
+ * @file conversation-buffer.test.ts
+ *
+ * The memory that lets the Conductor hold a conversation. Two of these tests are
+ * about forgetting rather than remembering, which is the half that goes wrong:
+ * a buffer that survives a dispatch makes the next request arrive wearing the
+ * last one's context.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { ConversationBuffer } from '../../../../main/acappella/router/conversation-buffer';
+
+describe('ConversationBuffer', () => {
+ it('keeps both halves of the exchange in order', () => {
+ const buffer = new ConversationBuffer();
+
+ buffer.add('user', 'the refresh keeps failing');
+ buffer.add('conductor', 'On the second load, or every time?');
+ buffer.add('user', 'second load');
+
+ expect(buffer.history).toEqual([
+ { role: 'user', text: 'the refresh keeps failing' },
+ { role: 'conductor', text: 'On the second load, or every time?' },
+ { role: 'user', text: 'second load' },
+ ]);
+ });
+
+ it('starts empty and reports it', () => {
+ const buffer = new ConversationBuffer();
+
+ expect(buffer.active).toBe(false);
+ expect(buffer.history).toEqual([]);
+ });
+
+ it('forgets everything on clear, which is what a dispatch does', () => {
+ // The discussion that produced a request is finished the moment it is sent.
+ const buffer = new ConversationBuffer();
+ buffer.add('user', 'fix the auth bug');
+
+ buffer.clear();
+
+ expect(buffer.active).toBe(false);
+ expect(buffer.history).toEqual([]);
+ });
+
+ it('ignores an empty line rather than recording a blank turn', () => {
+ const buffer = new ConversationBuffer();
+
+ buffer.add('user', ' ');
+
+ expect(buffer.active).toBe(false);
+ });
+
+ it('trims the text it records', () => {
+ const buffer = new ConversationBuffer();
+
+ buffer.add('user', ' spaced out ');
+
+ expect(buffer.history[0].text).toBe('spaced out');
+ });
+
+ it('drops the oldest turns once it is full', () => {
+ const buffer = new ConversationBuffer({ maxTurns: 3 });
+
+ for (const text of ['one', 'two', 'three', 'four']) buffer.add('user', text);
+
+ expect(buffer.history.map((turn) => turn.text)).toEqual(['two', 'three', 'four']);
+ });
+
+ it('drops by size too, since ten paragraphs is a prompt that keeps growing', () => {
+ const buffer = new ConversationBuffer({ maxTurns: 50, maxChars: 30 });
+
+ buffer.add('user', 'x'.repeat(25));
+ buffer.add('user', 'y'.repeat(25));
+
+ expect(buffer.history).toHaveLength(1);
+ expect(buffer.history[0].text.startsWith('y')).toBe(true);
+ });
+
+ it('never drops below one turn, however long that turn is', () => {
+ // The thing just said is the least droppable part of the context, even when
+ // it blows the budget on its own.
+ const buffer = new ConversationBuffer({ maxTurns: 50, maxChars: 10 });
+
+ buffer.add('user', 'z'.repeat(500));
+
+ expect(buffer.history).toHaveLength(1);
+ });
+
+ it('hands out a copy, so a caller cannot edit the memory in place', () => {
+ const buffer = new ConversationBuffer();
+ buffer.add('user', 'original');
+
+ buffer.history.push({ role: 'user', text: 'injected' });
+
+ expect(buffer.history).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/main/acappella/router/grammar.test.ts b/src/__tests__/main/acappella/router/grammar.test.ts
new file mode 100644
index 0000000000..f8aad0db0c
--- /dev/null
+++ b/src/__tests__/main/acappella/router/grammar.test.ts
@@ -0,0 +1,225 @@
+/**
+ * @file grammar.test.ts
+ *
+ * The compiled `RouteDecision` grammar, from both ends.
+ *
+ * The GBNF text is asserted structurally (it is what llama.cpp masks its sampler
+ * against, and a missing id alternative there is invisible until a model invents
+ * an agent), and the acceptance rules are asserted through `validate`, which is
+ * rendered from the SAME compiled node tree. That is the point of the two
+ * renderers: the suite can prove "the grammar rejects an unknown session id"
+ * without embedding a GBNF engine, and a drift between the grammar and the
+ * validator would have to be a drift within one tree.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ compileRouteDecisionGrammar,
+ rosterScope,
+ routeDecisionSchema,
+ validateRouteDecision,
+} from '../../../../main/acappella/router/grammar';
+import type { RosterAgent } from '../../../../shared/acappella/protocol';
+import type { RouteDecision } from '../../../../shared/acappella/route-decision';
+
+const ROSTER: RosterAgent[] = [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/api',
+ tabs: [
+ { id: 'tab-auth', name: 'Auth', lastActiveAt: 10 },
+ { id: 'tab-db', name: 'DB', lastActiveAt: 20 },
+ ],
+ },
+ {
+ sessionId: 'agent-frontend',
+ name: 'Frontend',
+ agentType: 'codex',
+ cwd: '/repo/web',
+ tabs: [{ id: 'tab-ui', name: 'Sidebar', lastActiveAt: 30 }],
+ },
+];
+
+function decision(overrides: Partial = {}): RouteDecision {
+ return {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'run the tests',
+ confidence: 0.9,
+ ...overrides,
+ };
+}
+
+describe('compileRouteDecisionGrammar - GBNF', () => {
+ it('emits a root rule and the JSON lexical prelude', () => {
+ const { gbnf } = compileRouteDecisionGrammar();
+
+ expect(gbnf).toMatch(/^root ::= /);
+ for (const rule of ['ws ::=', 'string ::=', 'number ::=', 'char ::=', 'hex ::=']) {
+ expect(gbnf).toContain(rule);
+ }
+ });
+
+ it('constrains the ids to exactly the ones in the roster', () => {
+ const { gbnf } = compileRouteDecisionGrammar(rosterScope(ROSTER));
+
+ expect(gbnf).toContain('"\\"agent-backend\\""');
+ expect(gbnf).toContain('"\\"agent-frontend\\""');
+ expect(gbnf).toContain('"\\"tab-auth\\""');
+ // Nothing that is not in the roster may appear as an alternative.
+ expect(gbnf).not.toContain('agent-ghost');
+ });
+
+ it('closes the tab action to the three known values', () => {
+ const { gbnf } = compileRouteDecisionGrammar();
+
+ expect(gbnf).toContain('"\\"current\\"" | "\\"new\\"" | "\\"recall\\""');
+ });
+
+ it('marks the optional fields optional and the required ones not', () => {
+ const { gbnf } = compileRouteDecisionGrammar();
+
+ // `tabId` may be omitted; `prompt` may not.
+ expect(gbnf).toMatch(/\("," ws "\\"tabId\\"" ws ":" ws string\)\?/);
+ expect(gbnf).toMatch(/"," ws "\\"prompt\\"" ws ":" ws string/);
+ expect(gbnf).not.toMatch(/\("," ws "\\"prompt\\""[^)]*\)\?/);
+ });
+});
+
+describe('compileRouteDecisionGrammar - acceptance', () => {
+ const grammar = compileRouteDecisionGrammar(rosterScope(ROSTER));
+
+ it('accepts a well-formed decision', () => {
+ expect(grammar.validate(decision()).ok).toBe(true);
+ });
+
+ it('accepts the conductor as a target', () => {
+ expect(grammar.validate(decision({ target: 'conductor' })).ok).toBe(true);
+ });
+
+ it('rejects a value that is not an object at all', () => {
+ expect(grammar.validate('sure, here you go: {}').ok).toBe(false);
+ expect(grammar.validate(null).ok).toBe(false);
+ expect(grammar.validate([decision()]).ok).toBe(false);
+ });
+
+ it('rejects an out-of-set tab action', () => {
+ const result = grammar.validate(decision({ tabAction: 'switch' as never }));
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('decision.tabAction');
+ });
+
+ it('rejects an invented session id', () => {
+ const result = grammar.validate(decision({ target: { sessionId: 'agent-ghost' } }));
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('decision.target');
+ });
+
+ it('rejects an invented tab id', () => {
+ const result = grammar.validate(decision({ tabAction: 'recall', tabId: 'tab-ghost' }));
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('decision.tabId');
+ });
+
+ it('rejects a missing required field', () => {
+ const { prompt, ...withoutPrompt } = decision();
+ void prompt;
+
+ const result = grammar.validate(withoutPrompt);
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('decision.prompt is required');
+ });
+
+ it('rejects a confidence outside 0 to 1', () => {
+ expect(grammar.validate(decision({ confidence: 7 })).ok).toBe(false);
+ expect(grammar.validate(decision({ confidence: -1 })).ok).toBe(false);
+ });
+
+ it('rejects a field nobody declared', () => {
+ const result = grammar.validate({ ...decision(), reasoning: 'because' });
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('reasoning');
+ });
+
+ it('leaves the ids free when the roster is empty', () => {
+ // An empty alternation would be a grammar that matches nothing, so an
+ // empty roster falls back to a plain string and validation carries the
+ // refusal instead.
+ const empty = compileRouteDecisionGrammar(rosterScope([]));
+
+ expect(empty.gbnf).not.toContain('()');
+ expect(empty.validate(decision()).ok).toBe(true);
+ });
+});
+
+describe('routeDecisionSchema', () => {
+ it('narrows the hosted schema to the roster, leaving the shape intact', () => {
+ const schema = routeDecisionSchema(rosterScope(ROSTER)) as any;
+
+ expect(schema.properties.tabId.enum).toEqual(['tab-auth', 'tab-db', 'tab-ui']);
+ const agentShape = schema.properties.target.oneOf.find((o: any) => o.type === 'object');
+ expect(agentShape.properties.sessionId.enum).toEqual(['agent-backend', 'agent-frontend']);
+ expect(schema.required).toEqual(['target', 'tabAction', 'prompt', 'confidence']);
+ });
+
+ it('does not mutate the shared schema', () => {
+ const first = routeDecisionSchema(rosterScope(ROSTER)) as any;
+ const second = routeDecisionSchema() as any;
+
+ expect(first.properties.tabId.enum).toBeDefined();
+ expect(second.properties.tabId.enum).toBeUndefined();
+ });
+});
+
+describe('validateRouteDecision', () => {
+ it('runs for every provider, hosted ones included', () => {
+ // The hosted tier asks for a structured output and gets one; the roster is
+ // still the only thing that knows whether the id is real.
+ const fromHostedProvider = decision({ target: { sessionId: 'agent-ghost' } });
+
+ expect(validateRouteDecision(fromHostedProvider, ROSTER).ok).toBe(false);
+ });
+
+ it('rejects a recall whose tab belongs to a different agent', () => {
+ const result = validateRouteDecision(
+ decision({ target: { sessionId: 'agent-backend' }, tabAction: 'recall', tabId: 'tab-ui' }),
+ ROSTER
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('is not a tab on "Backend"');
+ });
+
+ it('rejects a recall with no tab id', () => {
+ const result = validateRouteDecision(decision({ tabAction: 'recall' }), ROSTER);
+
+ expect(result.ok).toBe(false);
+ expect(result.errors.join(' ')).toContain('tabId is required');
+ });
+
+ it('accepts a decision carrying a clarification', () => {
+ const result = validateRouteDecision(
+ decision({ target: 'conductor', confidence: 0.2, clarify: 'Backend or Frontend?' }),
+ ROSTER
+ );
+
+ expect(result.ok).toBe(true);
+ });
+
+ it('ignores undefined optional fields rather than calling them unknown', () => {
+ const result = validateRouteDecision(
+ { ...decision(), tabId: undefined, tabName: undefined, clarify: undefined },
+ ROSTER
+ );
+
+ expect(result.ok).toBe(true);
+ });
+});
diff --git a/src/__tests__/main/acappella/router/routing-context.test.ts b/src/__tests__/main/acappella/router/routing-context.test.ts
new file mode 100644
index 0000000000..4645336652
--- /dev/null
+++ b/src/__tests__/main/acappella/router/routing-context.test.ts
@@ -0,0 +1,212 @@
+/**
+ * @file routing-context.test.ts
+ *
+ * The bounding rules, the tab states, and the promise that no second summarizer
+ * exists: every topic here comes out of data the app already had.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('../../../../main/stores/getters', () => ({ getSessionsStore: vi.fn() }));
+
+import {
+ buildRoutingContext,
+ buildRoutingRoster,
+ deriveTabTopic,
+ serializeRoutingContext,
+} from '../../../../main/acappella/router/routing-context';
+import type { StoredSession } from '../../../../main/stores/types';
+import { createMockSession } from '../../../helpers/mockSession';
+import { createMockAITab } from '../../../helpers/mockTab';
+
+function makeSession(overrides: Partial = {}): StoredSession {
+ return createMockSession(overrides as never) as unknown as StoredSession;
+}
+
+function sessions(): StoredSession[] {
+ return [
+ makeSession({
+ id: 'agent-backend',
+ name: 'Backend',
+ toolType: 'claude-code',
+ cwd: '/repo/api',
+ state: 'idle',
+ aiTabs: [
+ createMockAITab({
+ id: 'tab-auth',
+ name: 'Auth Refactor',
+ createdAt: 1_000,
+ logs: [
+ { id: 'l1', timestamp: 2_000, source: 'user', text: 'rewrite the auth middleware' },
+ { id: 'l2', timestamp: 4_000, source: 'ai', text: 'done' },
+ ] as never,
+ }),
+ createMockAITab({ id: 'tab-db', name: 'DB Migrations', createdAt: 9_000 }),
+ ],
+ }),
+ ];
+}
+
+describe('buildRoutingRoster', () => {
+ it('lists open tabs with a topic derived from the conversation', () => {
+ const [agent] = buildRoutingRoster(sessions());
+
+ expect(agent.tabs[0]).toMatchObject({
+ id: 'tab-auth',
+ name: 'Auth Refactor',
+ state: 'open',
+ topic: 'rewrite the auth middleware',
+ lastActiveAt: 4_000,
+ });
+ });
+
+ it('lists snoozed tabs so recall can reach them', () => {
+ const list = sessions();
+ list[0].snoozedTabs = [
+ {
+ id: 'snooze-1',
+ tab: createMockAITab({ id: 'tab-spike', name: 'Rate Limit Spike', createdAt: 500 }),
+ unifiedIndex: 0,
+ snoozedAt: 100,
+ wakeAt: 999_999,
+ },
+ ];
+
+ const [agent] = buildRoutingRoster(list);
+
+ expect(agent.tabs.find((tab) => tab.id === 'tab-spike')).toMatchObject({ state: 'snoozed' });
+ });
+
+ it('lists closed AI tabs and ignores closed file and terminal tabs', () => {
+ const list = sessions();
+ list[0].unifiedClosedTabHistory = [
+ { type: 'ai', tab: createMockAITab({ id: 'tab-old', name: 'Old Spike' }), index: 0 },
+ { type: 'terminal', tab: { id: 'term-1' }, index: 1 },
+ ];
+
+ const [agent] = buildRoutingRoster(list);
+
+ expect(agent.tabs.find((tab) => tab.id === 'tab-old')).toMatchObject({ state: 'closed' });
+ expect(agent.tabs.some((tab) => tab.id === 'term-1')).toBe(false);
+ });
+
+ it('omits hidden consult tabs', () => {
+ const list = sessions();
+ list[0].aiTabs.push(createMockAITab({ id: 'tab-consult', name: 'Consult', hidden: true }));
+
+ const [agent] = buildRoutingRoster(list);
+
+ expect(agent.tabs.some((tab) => tab.id === 'tab-consult')).toBe(false);
+ });
+
+ it('keeps the open copy when a tab appears open and closed at once', () => {
+ const list = sessions();
+ list[0].unifiedClosedTabHistory = [
+ { type: 'ai', tab: createMockAITab({ id: 'tab-auth', name: 'Auth Refactor' }), index: 0 },
+ ];
+
+ const [agent] = buildRoutingRoster(list);
+
+ expect(agent.tabs.filter((tab) => tab.id === 'tab-auth')).toHaveLength(1);
+ expect(agent.tabs.find((tab) => tab.id === 'tab-auth')?.state).toBe('open');
+ });
+
+ it('survives a session with no tabs at all', () => {
+ expect(buildRoutingRoster([makeSession({ id: 'a1', aiTabs: undefined })])[0].tabs).toEqual([]);
+ });
+});
+
+describe('deriveTabTopic', () => {
+ it('prefers the opening message, which the name already compresses', () => {
+ const tab = {
+ logs: [{ source: 'user', text: 'why is the migration locking the users table' }],
+ };
+
+ expect(deriveTabTopic(tab, 'DB Migrations')).toBe(
+ 'why is the migration locking the users table'
+ );
+ });
+
+ it('falls back to the name when the transcript has no user message', () => {
+ expect(deriveTabTopic({ logs: [] }, 'DB Migrations')).toBe('DB Migrations');
+ });
+
+ it('is null when there is nothing to say', () => {
+ expect(deriveTabTopic({ logs: [] }, null)).toBeNull();
+ });
+
+ it('truncates and collapses so a pasted stack trace is still one line', () => {
+ const topic = deriveTabTopic({ logs: [{ source: 'user', text: 'x\n\ty '.repeat(200) }] }, null);
+
+ expect(topic).toMatch(/…$/);
+ expect(topic!.length).toBeLessThanOrEqual(90);
+ expect(topic).not.toContain('\n');
+ });
+});
+
+describe('buildRoutingContext', () => {
+ it('carries the agent status and the history synopsis', () => {
+ const context = buildRoutingContext({
+ sessions: sessions(),
+ synopses: new Map([['agent-backend', 'Landed the auth refactor']]),
+ });
+
+ expect(context.agents[0].status).toBe('idle');
+ expect(context.agents[0].recentWork).toBe('Landed the auth refactor');
+ expect(serializeRoutingContext(context)).toContain('recently: Landed the auth refactor');
+ });
+
+ it('reports its own serialized size', () => {
+ const context = buildRoutingContext({ sessions: sessions() });
+
+ expect(context.serializedChars).toBe(serializeRoutingContext(context).length);
+ });
+
+ it('drops the least recently used tabs to stay under the cap, and says how many', () => {
+ const many = makeSession({
+ id: 'agent-busy',
+ name: 'Busy',
+ aiTabs: Array.from({ length: 40 }, (_, index) =>
+ createMockAITab({
+ id: `tab-${index}`,
+ name: `Conversation number ${index}`,
+ createdAt: index,
+ })
+ ),
+ });
+
+ const context = buildRoutingContext({ sessions: [many], maxChars: 400 });
+
+ expect(context.droppedTabs).toBeGreaterThan(0);
+ expect(context.serializedChars).toBeLessThanOrEqual(400);
+ // What survives is what the user was most recently doing.
+ expect(context.agents[0].tabs.map((tab) => tab.id)).toContain('tab-39');
+ expect(context.agents[0].tabs.map((tab) => tab.id)).not.toContain('tab-0');
+ });
+
+ it('never drops an agent, even under an impossible cap', () => {
+ const context = buildRoutingContext({ sessions: sessions(), maxChars: 1 });
+
+ // An agent missing from the roster cannot be routed to at all; a missing
+ // tab only costs a recall the user can repeat with more words.
+ expect(context.agents).toHaveLength(1);
+ expect(context.agents[0].tabs.length).toBeGreaterThan(0);
+ });
+
+ it('serializes an empty roster without pretending anything is running', () => {
+ const context = buildRoutingContext({ sessions: [] });
+
+ expect(serializeRoutingContext(context)).toContain('(none)');
+ });
+
+ it('includes the voice conversation, not the agent transcripts', () => {
+ const context = buildRoutingContext({
+ sessions: sessions(),
+ recentUtterances: ['run the tests', 'what broke'],
+ });
+
+ const text = serializeRoutingContext(context);
+ expect(text).toContain('Earlier in this conversation:');
+ expect(text).toContain('what broke');
+ });
+});
diff --git a/src/__tests__/main/acappella/router/routing-log.test.ts b/src/__tests__/main/acappella/router/routing-log.test.ts
new file mode 100644
index 0000000000..743159def3
--- /dev/null
+++ b/src/__tests__/main/acappella/router/routing-log.test.ts
@@ -0,0 +1,209 @@
+/**
+ * @file routing-log.test.ts
+ *
+ * The log, and the one number it exists to produce: a hit rate that counts a
+ * dispatch the user immediately corrected as a miss.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+vi.mock('electron', () => ({ app: { getPath: () => '/nonexistent' } }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import {
+ flushRoutingLog,
+ lastRoutingTurn,
+ loadRoutingLog,
+ MAX_ENTRIES,
+ noteRoutingOutcome,
+ readRoutingLog,
+ recordRoutingTurn,
+ resetRoutingLog,
+ routingQuality,
+ setRoutingLogPath,
+} from '../../../../main/acappella/router/routing-log';
+import type { RouteDecision } from '../../../../shared/acappella/route-decision';
+
+function decision(overrides: Partial = {}): RouteDecision {
+ return {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'run the tests',
+ confidence: 0.9,
+ ...overrides,
+ };
+}
+
+function record(id: string, overrides: Partial[0]> = {}) {
+ return recordRoutingTurn({
+ id,
+ utterance: 'run the tests',
+ decision: decision(),
+ brainProviderId: 'qwen3-local',
+ latencyMs: 120,
+ contextChars: 900,
+ ...overrides,
+ });
+}
+
+let tempDir: string;
+
+beforeEach(async () => {
+ resetRoutingLog();
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-routing-log-'));
+ setRoutingLogPath(path.join(tempDir, 'routing-log.json'));
+});
+
+afterEach(async () => {
+ resetRoutingLog();
+ setRoutingLogPath(null);
+ await fs.rm(tempDir, { recursive: true, force: true });
+});
+
+describe('recordRoutingTurn', () => {
+ it('flattens the decision into one readable entry', () => {
+ record('turn-1');
+
+ expect(readRoutingLog()[0]).toMatchObject({
+ id: 'turn-1',
+ utterance: 'run the tests',
+ targetSessionId: 'agent-backend',
+ tabAction: 'current',
+ confidence: 0.9,
+ latencyMs: 120,
+ contextChars: 900,
+ outcome: 'dispatched',
+ });
+ });
+
+ it('records a question as clarified rather than dispatched', () => {
+ record('turn-1', { decision: decision({ clarify: 'Backend or API?', confidence: 0.3 }) });
+
+ expect(readRoutingLog()[0].outcome).toBe('clarified');
+ });
+
+ it('truncates the utterance: this is a log, not a transcript', () => {
+ record('turn-1', { utterance: 'a'.repeat(1000) });
+
+ expect(readRoutingLog()[0].utterance.length).toBeLessThanOrEqual(200);
+ });
+
+ it('keeps the newest entries when it overflows', () => {
+ for (let index = 0; index < MAX_ENTRIES + 10; index++) record(`turn-${index}`);
+
+ const entries = readRoutingLog();
+ expect(entries).toHaveLength(MAX_ENTRIES);
+ expect(entries[entries.length - 1].id).toBe(`turn-${MAX_ENTRIES + 9}`);
+ });
+
+ it('hands back a copy, so a reader cannot rewrite history', () => {
+ record('turn-1');
+
+ readRoutingLog()[0].outcome = 'failed';
+
+ expect(readRoutingLog()[0].outcome).toBe('dispatched');
+ });
+});
+
+describe('noteRoutingOutcome', () => {
+ it('attaches what actually happened', () => {
+ record('turn-1');
+
+ noteRoutingOutcome('turn-1', 'corrected', 'moved to API');
+
+ expect(readRoutingLog()[0]).toMatchObject({ outcome: 'corrected', detail: 'moved to API' });
+ });
+
+ it('ignores an id that has aged out', () => {
+ record('turn-1');
+
+ expect(() => noteRoutingOutcome('turn-gone', 'failed')).not.toThrow();
+ expect(readRoutingLog()).toHaveLength(1);
+ });
+});
+
+describe('routingQuality', () => {
+ it('counts a correction as a miss even though nothing errored', () => {
+ record('turn-1');
+ record('turn-2');
+ record('turn-3');
+ noteRoutingOutcome('turn-2', 'corrected');
+
+ const quality = routingQuality();
+
+ expect(quality).toMatchObject({ turns: 3, dispatched: 2, corrected: 1 });
+ expect(quality.hitRate).toBeCloseTo(2 / 3);
+ });
+
+ it('excludes clarifications from the hit rate entirely', () => {
+ record('turn-1');
+ record('turn-2', { decision: decision({ clarify: 'Backend or API?' }) });
+
+ const quality = routingQuality();
+
+ expect(quality.clarified).toBe(1);
+ // Asking is the correct behaviour below the threshold. Counting it either
+ // way would make the threshold impossible to tune.
+ expect(quality.hitRate).toBe(1);
+ });
+
+ it('reports no hit rate before anything has been decided', () => {
+ expect(routingQuality().hitRate).toBeNull();
+ });
+
+ it('averages the routing latency', () => {
+ record('turn-1', { latencyMs: 100 });
+ record('turn-2', { latencyMs: 300 });
+
+ expect(routingQuality().meanLatencyMs).toBe(200);
+ });
+});
+
+describe('persistence', () => {
+ it('writes atomically and reads back', async () => {
+ record('turn-1');
+ await flushRoutingLog();
+
+ resetRoutingLog();
+ await loadRoutingLog();
+
+ expect(readRoutingLog()[0].id).toBe('turn-1');
+ });
+
+ it('starts fresh rather than failing when the file is unreadable', async () => {
+ await fs.writeFile(path.join(tempDir, 'routing-log.json'), 'not json');
+
+ await loadRoutingLog();
+
+ expect(readRoutingLog()).toEqual([]);
+ });
+
+ it('survives a directory that does not exist yet', async () => {
+ setRoutingLogPath(path.join(tempDir, 'nested', 'deeper', 'routing-log.json'));
+ record('turn-1');
+
+ await flushRoutingLog();
+
+ const raw = await fs.readFile(
+ path.join(tempDir, 'nested', 'deeper', 'routing-log.json'),
+ 'utf-8'
+ );
+ expect(JSON.parse(raw)[0].id).toBe('turn-1');
+ });
+});
+
+describe('lastRoutingTurn', () => {
+ it('is null before the first decision, then the newest one', () => {
+ expect(lastRoutingTurn()).toBeNull();
+
+ record('turn-1');
+ record('turn-2');
+
+ expect(lastRoutingTurn()?.id).toBe('turn-2');
+ });
+});
diff --git a/src/__tests__/main/acappella/router/tab-recall.test.ts b/src/__tests__/main/acappella/router/tab-recall.test.ts
new file mode 100644
index 0000000000..f1aa1cc04d
--- /dev/null
+++ b/src/__tests__/main/acappella/router/tab-recall.test.ts
@@ -0,0 +1,217 @@
+/**
+ * @file tab-recall.test.ts
+ *
+ * Ranking, and the two tab states that have a wrong answer which looks like
+ * success: a snoozed tab focused without being woken, and a closed tab quietly
+ * replaced by an empty new one.
+ *
+ * `now` is injected throughout, because recency is half the score and a suite
+ * whose expectations drift with the wall clock is worse than no suite.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ narrowRosterForRecall,
+ rankRecallCandidates,
+ resolveRecall,
+} from '../../../../main/acappella/router/tab-recall';
+import type { RosterAgent, RosterTab } from '../../../../shared/acappella/protocol';
+import type { RouteDecision } from '../../../../shared/acappella/route-decision';
+
+const NOW = 1_000_000_000;
+const MINUTE = 60_000;
+const DAY = 24 * 60 * MINUTE;
+
+function tab(overrides: Partial & { id: string }): RosterTab {
+ return { name: null, lastActiveAt: NOW - MINUTE, state: 'open', topic: null, ...overrides };
+}
+
+function roster(): RosterAgent[] {
+ return [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo/payments-api',
+ tabs: [
+ tab({ id: 'tab-auth', name: 'Auth Refactor', lastActiveAt: NOW - 3 * DAY }),
+ tab({ id: 'tab-db', name: 'DB Migrations', lastActiveAt: NOW - MINUTE }),
+ ],
+ },
+ {
+ sessionId: 'agent-frontend',
+ name: 'Frontend',
+ agentType: 'codex',
+ cwd: '/repo/web',
+ tabs: [
+ tab({
+ id: 'tab-ui',
+ name: 'Sidebar',
+ topic: 'make the sidebar collapse on narrow screens',
+ lastActiveAt: NOW - 2 * MINUTE,
+ }),
+ ],
+ },
+ ];
+}
+
+function decision(overrides: Partial = {}): RouteDecision {
+ return {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'recall',
+ prompt: 'did we land that fix?',
+ confidence: 0.7,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Ranking
+// ---------------------------------------------------------------------------
+
+describe('rankRecallCandidates', () => {
+ it('puts a name match ahead of a much more recent tab', () => {
+ const ranked = rankRecallCandidates('back to the auth thing', roster(), { now: NOW });
+
+ // tab-db was touched a minute ago and tab-auth three days ago. Recall that
+ // meant "the most recent tab" would be a feature nobody needs to speak.
+ expect(ranked[0].tab.id).toBe('tab-auth');
+ });
+
+ it('matches on the topic when the tab was never named after it', () => {
+ const ranked = rankRecallCandidates('the sidebar collapse conversation', roster(), {
+ now: NOW,
+ });
+
+ expect(ranked[0].tab.id).toBe('tab-ui');
+ expect(ranked[0].reasons.join(' ')).toContain('Sidebar');
+ });
+
+ it('scores a mentioned project path', () => {
+ const ranked = rankRecallCandidates('what did payments say', roster(), { now: NOW });
+
+ expect(ranked.map((candidate) => candidate.agentSessionId)).toContain('agent-backend');
+ expect(ranked[0].reasons.join(' ')).toContain('project path mentioned');
+ });
+
+ it('biases toward the agent already in play when scores are otherwise level', () => {
+ const withBias = rankRecallCandidates('migrations', roster(), {
+ now: NOW,
+ activeAgentSessionId: 'agent-backend',
+ });
+
+ expect(withBias[0].agentSessionId).toBe('agent-backend');
+ expect(withBias[0].reasons).toContain('same agent as the current turn');
+ });
+
+ it('returns nothing rather than padding the list with noise', () => {
+ expect(rankRecallCandidates('what is the weather', roster(), { now: NOW })).toEqual([]);
+ });
+
+ it('honours the limit', () => {
+ const ranked = rankRecallCandidates('auth db sidebar', roster(), { now: NOW, limit: 1 });
+
+ expect(ranked).toHaveLength(1);
+ });
+
+ it('ignores stop words, so a filler-only utterance matches nothing', () => {
+ expect(rankRecallCandidates('go back to the one thing', roster(), { now: NOW })).toEqual([]);
+ });
+});
+
+describe('narrowRosterForRecall', () => {
+ it('keeps every agent, even one with no shortlisted tab', () => {
+ const agents = roster();
+ const candidates = rankRecallCandidates('auth', agents, { now: NOW });
+
+ const narrowed = narrowRosterForRecall(agents, candidates);
+
+ expect(narrowed.map((agent) => agent.sessionId)).toEqual(['agent-backend', 'agent-frontend']);
+ });
+
+ it('keeps open tabs and drops the put-away ones that did not shortlist', () => {
+ const agents = roster();
+ agents[0].tabs.push(tab({ id: 'tab-old', name: 'Old Spike', state: 'closed' }));
+ const candidates = rankRecallCandidates('auth', agents, { now: NOW });
+
+ const narrowed = narrowRosterForRecall(agents, candidates);
+
+ const ids = narrowed.flatMap((agent) => agent.tabs.map((entry) => entry.id));
+ expect(ids).toContain('tab-db');
+ expect(ids).not.toContain('tab-old');
+ });
+
+ it('keeps a shortlisted closed tab', () => {
+ const agents = roster();
+ agents[0].tabs.push(tab({ id: 'tab-old', name: 'Auth Spike', state: 'closed' }));
+ const candidates = rankRecallCandidates('auth spike', agents, { now: NOW });
+
+ const narrowed = narrowRosterForRecall(agents, candidates);
+
+ expect(narrowed[0].tabs.map((entry) => entry.id)).toContain('tab-old');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Resolution
+// ---------------------------------------------------------------------------
+
+describe('resolveRecall', () => {
+ it('focuses an open tab', () => {
+ const result = resolveRecall(decision({ tabId: 'tab-auth' }), roster());
+
+ expect(result).toMatchObject({ kind: 'focus', agentSessionId: 'agent-backend' });
+ });
+
+ it('wakes a snoozed tab rather than focusing a tab that is not on screen', () => {
+ const agents = roster();
+ agents[0].tabs[0] = { ...agents[0].tabs[0], state: 'snoozed' };
+
+ const result = resolveRecall(decision({ tabId: 'tab-auth' }), agents);
+
+ expect(result.kind).toBe('wake');
+ });
+
+ it('offers to reopen a closed tab instead of duplicating it', () => {
+ const agents = roster();
+ agents[0].tabs[0] = { ...agents[0].tabs[0], state: 'closed' };
+
+ const result = resolveRecall(decision({ tabId: 'tab-auth' }), agents);
+
+ expect(result.kind).toBe('offer');
+ if (result.kind !== 'offer') throw new Error('expected an offer');
+ expect(result.question).toContain('Auth Refactor');
+ expect(result.question).toContain('reopen');
+ });
+
+ it('reopens a closed tab once the offer has been answered', () => {
+ const agents = roster();
+ agents[0].tabs[0] = { ...agents[0].tabs[0], state: 'closed' };
+
+ const result = resolveRecall(decision({ tabId: 'tab-auth' }), agents, { confirmed: true });
+
+ expect(result.kind).toBe('reopen');
+ });
+
+ it('finds a tab the Brain attributed to the wrong agent', () => {
+ // The conversation was identified correctly; only the owner was wrong.
+ const result = resolveRecall(
+ decision({ target: { sessionId: 'agent-backend' }, tabId: 'tab-ui' }),
+ roster()
+ );
+
+ expect(result).toMatchObject({ kind: 'focus', agentSessionId: 'agent-frontend' });
+ });
+
+ it('reports a tab that no longer exists as missing', () => {
+ expect(resolveRecall(decision({ tabId: 'tab-ghost' }), roster())).toEqual({
+ kind: 'missing',
+ tabId: 'tab-ghost',
+ });
+ });
+
+ it('reports a recall with no tab id as missing', () => {
+ expect(resolveRecall(decision(), roster())).toEqual({ kind: 'missing', tabId: undefined });
+ });
+});
diff --git a/src/__tests__/main/acappella/runtime/native-loader.test.ts b/src/__tests__/main/acappella/runtime/native-loader.test.ts
new file mode 100644
index 0000000000..7cf6a6c628
--- /dev/null
+++ b/src/__tests__/main/acappella/runtime/native-loader.test.ts
@@ -0,0 +1,442 @@
+/**
+ * @file native-loader.test.ts
+ *
+ * The loader has three promises, and every one of them is invisible in
+ * development and expensive in production:
+ *
+ * 1. Nothing native is imported until someone asks for it. A user with the
+ * Encore Feature off must not pay a single dlopen, and the way that breaks
+ * is a static import added anywhere else in the codebase, so the first test
+ * here scans the source rather than the runtime.
+ * 2. A failure comes back structured. A dlopen error string in front of a user
+ * is a bug report with no information in it.
+ * 3. A failed load never takes the app down.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+/**
+ * A stand-in registry.
+ *
+ * The real one has every runtime at `declared: false` (the packages land with
+ * the providers in Phase 05), so testing the load paths against it would only
+ * ever exercise the not-a-dependency branch. This mock keeps the same shape and
+ * the same helper behaviour with one declared and one undeclared runtime.
+ */
+vi.mock('../../../../shared/acappella/native-runtimes', () => {
+ const runtimes = [
+ {
+ id: 'llama',
+ moduleId: 'fake-llama',
+ versionPin: '1.0.0',
+ label: 'Fake llama',
+ slots: ['brain'],
+ declared: true,
+ requiresElectronRebuild: false,
+ prebuilds: {
+ 'darwin-arm64': 'prebuilt',
+ 'darwin-x64': 'prebuilt',
+ 'win32-x64': 'prebuilt',
+ 'linux-x64': 'prebuilt',
+ },
+ asarUnpack: [],
+ packagedBinaries: {
+ 'darwin-arm64': [],
+ 'darwin-x64': [],
+ 'win32-x64': [],
+ 'linux-x64': [],
+ },
+ rationale: '',
+ notes: '',
+ },
+ {
+ id: 'whisper',
+ moduleId: 'fake-whisper',
+ versionPin: '1.0.0',
+ label: 'Fake whisper',
+ slots: ['stt'],
+ declared: false,
+ requiresElectronRebuild: false,
+ prebuilds: {
+ 'darwin-arm64': 'source-build',
+ 'darwin-x64': 'source-build',
+ 'win32-x64': 'source-build',
+ 'linux-x64': 'source-build',
+ },
+ asarUnpack: [],
+ packagedBinaries: {
+ 'darwin-arm64': [],
+ 'darwin-x64': [],
+ 'win32-x64': [],
+ 'linux-x64': [],
+ },
+ rationale: '',
+ notes: '',
+ },
+ {
+ id: 'onnx',
+ moduleId: 'fake-onnx',
+ versionPin: '1.0.0',
+ label: 'Fake onnx',
+ slots: ['tts', 'wake-word'],
+ declared: true,
+ requiresElectronRebuild: false,
+ // Deliberately shipped nowhere, to exercise the unsupported-platform path.
+ prebuilds: {
+ 'darwin-arm64': 'unavailable',
+ 'darwin-x64': 'unavailable',
+ 'win32-x64': 'unavailable',
+ 'linux-x64': 'unavailable',
+ },
+ asarUnpack: [],
+ packagedBinaries: {
+ 'darwin-arm64': [],
+ 'darwin-x64': [],
+ 'win32-x64': [],
+ 'linux-x64': [],
+ },
+ rationale: '',
+ notes: '',
+ },
+ ];
+
+ return {
+ NATIVE_RUNTIMES: runtimes,
+ getNativeRuntime: (id: string) => runtimes.find((runtime) => runtime.id === id),
+ nativePlatformKey: (platform: string, arch: string) => {
+ const key = `${platform}-${arch}`;
+ return ['darwin-arm64', 'darwin-x64', 'win32-x64', 'linux-x64'].includes(key) ? key : null;
+ },
+ };
+});
+
+import {
+ NativeRuntimeUnavailableError,
+ __setNativeImporter,
+ allNativeRuntimeFailures,
+ describeRuntimeUnavailable,
+ isNativeRuntimeLoaded,
+ knownNativeRuntimeUnavailability,
+ lastNativeRuntimeFailure,
+ loadNativeRuntime,
+ resetNativeRuntimes,
+ tryLoadNativeRuntime,
+ unloadNativeRuntime,
+} from '../../../../main/acappella/runtime/native-loader';
+
+const REAL_PLATFORM = process.platform;
+const REAL_ARCH = process.arch;
+
+function setPlatform(platform: string, arch: string): void {
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true });
+ Object.defineProperty(process, 'arch', { value: arch, configurable: true });
+}
+
+function moduleNotFound(): NodeJS.ErrnoException {
+ const error: NodeJS.ErrnoException = new Error("Cannot find module 'fake-llama'");
+ error.code = 'MODULE_NOT_FOUND';
+ return error;
+}
+
+describe('native-loader', () => {
+ beforeEach(() => {
+ resetNativeRuntimes();
+ setPlatform('darwin', 'arm64');
+ });
+
+ afterEach(() => {
+ __setNativeImporter(null);
+ resetNativeRuntimes();
+ setPlatform(REAL_PLATFORM, REAL_ARCH);
+ });
+
+ describe('lazy loading', () => {
+ it('imports nothing until a runtime is asked for', async () => {
+ const importer = vi.fn().mockResolvedValue({});
+ __setNativeImporter(importer);
+
+ // Importing this module, and reading the registry through it, must not
+ // have reached the module system.
+ expect(importer).not.toHaveBeenCalled();
+ expect(isNativeRuntimeLoaded('llama')).toBe(false);
+
+ await tryLoadNativeRuntime('llama');
+ expect(importer).toHaveBeenCalledWith('fake-llama');
+ });
+
+ it('loads a runtime once, however many callers ask', async () => {
+ const importer = vi.fn().mockResolvedValue({ marker: 1 });
+ __setNativeImporter(importer);
+
+ const [first, second] = await Promise.all([
+ loadNativeRuntime<{ marker: number }>('llama'),
+ loadNativeRuntime<{ marker: number }>('llama'),
+ ]);
+
+ expect(first).toBe(second);
+ expect(importer).toHaveBeenCalledTimes(1);
+ });
+
+ it('is the only module in the codebase that imports a native runtime', () => {
+ // The runtime invariant cannot be observed at runtime: a stray top-level
+ // `import 'node-llama-cpp'` in some provider would break the "Encore off
+ // costs nothing" property silently and permanently. So the source itself
+ // is the assertion.
+ const roots = ['src/main', 'src/renderer', 'src/shared', 'src/cli'];
+ const nativeModules = ['node-llama-cpp', 'smart-whisper', 'onnxruntime-node'];
+ const offenders: string[] = [];
+
+ for (const root of roots) {
+ for (const file of walkTypeScript(path.resolve(process.cwd(), root))) {
+ const source = fs.readFileSync(file, 'utf8');
+ for (const moduleId of nativeModules) {
+ // A static import or require of the package by name. The loader
+ // reaches these through a variable specifier, so it never matches.
+ const staticImport = new RegExp(
+ `(from\\s+['"]${moduleId}['"])|(require\\(['"]${moduleId}['"]\\))|(import\\(['"]${moduleId}['"]\\))`
+ );
+ if (staticImport.test(source)) offenders.push(`${file} -> ${moduleId}`);
+ }
+ }
+ }
+
+ expect(offenders).toEqual([]);
+ });
+ });
+
+ describe('structured failures', () => {
+ it('reports a missing module with the runtime, module, platform, and cause', async () => {
+ __setNativeImporter(vi.fn().mockRejectedValue(moduleNotFound()));
+
+ const result = await tryLoadNativeRuntime('llama');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.kind).toBe('runtime-unavailable');
+ expect(result.error.failure).toBe('module-not-found');
+ expect(result.error.runtimeId).toBe('llama');
+ expect(result.error.moduleId).toBe('fake-llama');
+ expect(result.error.platform).toBe('darwin');
+ expect(result.error.arch).toBe('arm64');
+ expect(result.error.detail).toContain('Cannot find module');
+ // Never a bare dlopen string: there is always a sentence and a next step.
+ expect(result.error.message).not.toBe('');
+ expect(result.error.suggestedAction).not.toBe('');
+ });
+
+ it('classifies an arbitrary load error as load-failed, keeping the cause', async () => {
+ __setNativeImporter(
+ vi.fn().mockRejectedValue(new Error('dlopen(libggml.dylib): symbol not found'))
+ );
+
+ const result = await tryLoadNativeRuntime('llama');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.failure).toBe('load-failed');
+ expect(result.error.detail).toContain('symbol not found');
+ });
+
+ it('names the Visual C++ redistributable when Windows cannot find a dependent DLL', async () => {
+ setPlatform('win32', 'x64');
+ __setNativeImporter(
+ vi
+ .fn()
+ .mockRejectedValue(
+ new Error('\\\\?\\C:\\app\\llama-addon.node: The specified module could not be found.')
+ )
+ );
+
+ const result = await tryLoadNativeRuntime('llama');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ // Windows names the addon rather than the DLL it actually wanted, so the
+ // raw message reads like a corrupt install and sends the user to reinstall
+ // the app, which changes nothing.
+ expect(result.error.suggestedAction).toContain('Visual C++ Redistributable');
+ });
+
+ it('says "not a dependency" without touching the module system', async () => {
+ const importer = vi.fn();
+ __setNativeImporter(importer);
+
+ const result = await tryLoadNativeRuntime('whisper');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.failure).toBe('not-a-dependency');
+ // The distinction that matters: this is not a broken install, so nothing
+ // was attempted and nothing should be reinstalled.
+ expect(importer).not.toHaveBeenCalled();
+ });
+
+ it('says "unsupported platform" for a runtime with no build here', async () => {
+ const importer = vi.fn();
+ __setNativeImporter(importer);
+
+ const result = await tryLoadNativeRuntime('onnx');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.failure).toBe('unsupported-platform');
+ expect(importer).not.toHaveBeenCalled();
+ });
+
+ it('reports an unknown platform/arch pair as unsupported rather than crashing', async () => {
+ setPlatform('sunos', 'sparc');
+ __setNativeImporter(vi.fn());
+
+ const result = await tryLoadNativeRuntime('llama');
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.failure).toBe('unsupported-platform');
+ expect(result.error.message).toContain('sunos-sparc');
+ });
+
+ it('formats a failure as one line for a log or support report', () => {
+ const line = describeRuntimeUnavailable({
+ kind: 'runtime-unavailable',
+ runtimeId: 'llama',
+ moduleId: 'fake-llama',
+ platform: 'darwin',
+ arch: 'arm64',
+ failure: 'load-failed',
+ message: 'Fake llama failed to load.',
+ suggestedAction: 'Run the self-test.',
+ detail: 'symbol not found',
+ });
+
+ expect(line).toContain('fake-llama');
+ expect(line).toContain('load-failed');
+ expect(line).toContain('symbol not found');
+ });
+ });
+
+ describe('a failed load does not crash the app', () => {
+ it('never rejects from tryLoadNativeRuntime', async () => {
+ __setNativeImporter(vi.fn().mockRejectedValue(new Error('boom')));
+ await expect(tryLoadNativeRuntime('llama')).resolves.toMatchObject({ ok: false });
+ });
+
+ it('throws a typed error from loadNativeRuntime, carrying the same structure', async () => {
+ __setNativeImporter(vi.fn().mockRejectedValue(moduleNotFound()));
+
+ await expect(loadNativeRuntime('llama')).rejects.toBeInstanceOf(
+ NativeRuntimeUnavailableError
+ );
+ await expect(loadNativeRuntime('llama')).rejects.toMatchObject({
+ info: { failure: 'module-not-found', moduleId: 'fake-llama' },
+ });
+ });
+
+ it('reports an unknown runtime id instead of throwing', async () => {
+ const result = await tryLoadNativeRuntime('nope' as 'llama');
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+ expect(result.error.message).toContain('Unknown native runtime');
+ });
+ });
+
+ describe('remembered failures', () => {
+ it('remembers the last failure so the capability gate can explain it without loading', async () => {
+ __setNativeImporter(vi.fn().mockRejectedValue(new Error('boom')));
+ await tryLoadNativeRuntime('llama');
+
+ expect(lastNativeRuntimeFailure('llama')?.failure).toBe('load-failed');
+ expect(allNativeRuntimeFailures()).toHaveLength(1);
+ });
+
+ it('retries after a failure rather than replaying it forever', async () => {
+ const importer = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('boom'))
+ .mockResolvedValueOnce({ ok: true });
+ __setNativeImporter(importer);
+
+ expect((await tryLoadNativeRuntime('llama')).ok).toBe(false);
+ // The user installed the missing piece; the next attempt must be real.
+ expect((await tryLoadNativeRuntime('llama')).ok).toBe(true);
+ expect(importer).toHaveBeenCalledTimes(2);
+ expect(lastNativeRuntimeFailure('llama')).toBeNull();
+ });
+
+ it('forgets a runtime on unload so a later load is a fresh attempt', async () => {
+ const importer = vi.fn().mockResolvedValue({});
+ __setNativeImporter(importer);
+
+ await tryLoadNativeRuntime('llama');
+ expect(isNativeRuntimeLoaded('llama')).toBe(true);
+
+ unloadNativeRuntime('llama');
+ expect(isNativeRuntimeLoaded('llama')).toBe(false);
+
+ await tryLoadNativeRuntime('llama');
+ expect(importer).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ /**
+ * The question a capability gate actually has is "will this work here", not
+ * "has this already gone wrong here". Answering the second one in place of the
+ * first is how readiness came back "everything satisfied" on a fresh boot for
+ * runtimes that are not in the build at all, and the opposite answer once
+ * anything had attempted a load.
+ */
+ describe('known unavailability, without loading', () => {
+ it('reports a runtime that is not a dependency before anything tries it', () => {
+ const importer = vi.fn();
+ __setNativeImporter(importer);
+
+ const verdict = knownNativeRuntimeUnavailability('whisper');
+
+ expect(verdict?.failure).toBe('not-a-dependency');
+ expect(importer).not.toHaveBeenCalled();
+ });
+
+ it('reports a platform with no build before anything tries it', () => {
+ setPlatform('darwin', 'arm64');
+
+ // `onnx` is declared but shipped nowhere in the stand-in registry.
+ expect(knownNativeRuntimeUnavailability('onnx')?.failure).toBe('unsupported-platform');
+ });
+
+ it('is null for a runtime that should load', () => {
+ setPlatform('darwin', 'arm64');
+
+ expect(knownNativeRuntimeUnavailability('llama')).toBeNull();
+ });
+
+ it('prefers what actually happened over what the registry predicts', async () => {
+ setPlatform('darwin', 'arm64');
+ __setNativeImporter(vi.fn().mockRejectedValue(new Error('boom')));
+ await tryLoadNativeRuntime('llama');
+
+ expect(knownNativeRuntimeUnavailability('llama')?.failure).toBe('load-failed');
+ });
+
+ it('does not record a failure nobody hit', () => {
+ knownNativeRuntimeUnavailability('whisper');
+
+ // Asking must not put anything in the support report: the debug package
+ // lists failures that HAPPENED, not answers to hypothetical questions.
+ expect(allNativeRuntimeFailures()).toHaveLength(0);
+ expect(lastNativeRuntimeFailure('whisper')).toBeNull();
+ });
+ });
+});
+
+/** Every .ts/.tsx file under a directory, skipping the test tree itself. */
+function walkTypeScript(dir: string, out: string[] = []): string[] {
+ if (!fs.existsSync(dir)) return out;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) walkTypeScript(full, out);
+ else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) out.push(full);
+ }
+ return out;
+}
diff --git a/src/__tests__/main/acappella/runtime/runtime-installer.test.ts b/src/__tests__/main/acappella/runtime/runtime-installer.test.ts
new file mode 100644
index 0000000000..51cf3f2fcf
--- /dev/null
+++ b/src/__tests__/main/acappella/runtime/runtime-installer.test.ts
@@ -0,0 +1,323 @@
+/**
+ * @file runtime-installer.test.ts
+ *
+ * The install transaction, driven end to end against a real tarball with no
+ * network. Everything here is offline and deterministic: the archive is built in
+ * a temp directory, hashed, and handed back through an injected `fetch`, so the
+ * test exercises the real streaming download, the real SHA-256 comparison, the
+ * real node-tar extraction, and the real promote-and-commit ordering.
+ *
+ * The properties worth protecting, each of which was a design decision rather
+ * than an accident:
+ *
+ * - Nothing appears at the install path until the manifest is written, so a
+ * failure part-way through can never look like a finished install.
+ * - A hash mismatch is fatal and leaves no bytes behind, because the payload is
+ * code that will later be dlopen'd.
+ * - Only the running platform's subtree is written, which is the entire reason
+ * a 101 MB download costs 37 MB of disk.
+ * - A reinstall REPLACES rather than merges, so a stale binary cannot outlive
+ * the version that shipped it.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { createHash } from 'crypto';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+import * as tar from 'tar';
+
+/**
+ * A synthetic payload shaped like the real ONNX Runtime tarball: an npm root, a
+ * little JavaScript, the platform we want, a platform we do not, and a file
+ * outside every kept prefix.
+ */
+const ARCHIVE_FILES: Record = {
+ 'package/package.json': '{"name":"fake-runtime","main":"dist/index.js"}',
+ 'package/dist/index.js': 'module.exports = {};',
+ 'package/bin/napi-v6/darwin/arm64/onnxruntime_binding.node': 'ARM64 BINDING',
+ 'package/bin/napi-v6/darwin/arm64/libonnxruntime.1.dylib': 'ARM64 DYLIB',
+ 'package/bin/napi-v6/win32/x64/onnxruntime_binding.node': 'WINDOWS BINDING',
+ 'package/bin/napi-v6/win32/x64/onnxruntime.dll': 'WINDOWS DLL',
+ 'package/README.md': 'not kept',
+};
+
+/**
+ * The artifact the store and installer see, mutated per test.
+ *
+ * Hoisted because `vi.mock` factories run before the module body, and a factory
+ * that closed over an ordinary `const` would read it before initialisation.
+ */
+const { artifact } = vi.hoisted(() => ({
+ artifact: {
+ runtimeId: 'onnx' as const,
+ platform: 'darwin-arm64' as const,
+ url: 'https://registry.npmjs.org/fake-runtime/-/fake-runtime-1.27.0.tgz',
+ sha256: '',
+ bytes: 0,
+ stripComponents: 1,
+ keep: ['dist', 'package.json', 'bin/napi-v6/darwin/arm64'],
+ entry: 'dist/index.js',
+ binary: 'bin/napi-v6/darwin/arm64/onnxruntime_binding.node',
+ },
+}));
+
+const ENTRY = artifact.entry;
+const BINARY = artifact.binary;
+const URL = artifact.url;
+
+let tempRoot: string;
+let tarballBytes: Buffer;
+let tarballSha256: string;
+
+vi.mock('../../../../shared/acappella/runtime-artifacts', () => ({
+ nativeRuntimeArtifact: () => artifact,
+ isNativeRuntimeDownloadable: () => true,
+ nativeRuntimeDownloadBytes: () => artifact.bytes,
+ NATIVE_RUNTIME_ARTIFACTS: [artifact],
+}));
+
+import {
+ installNativeRuntime,
+ RuntimeBinaryMissingError,
+ RuntimeHashMismatchError,
+} from '../../../../main/acappella/runtime/runtime-installer';
+import {
+ installedRuntimeEntry,
+ isRuntimeInstalled,
+ readRuntimeManifest,
+ removeRuntime,
+ runtimeDir,
+ runtimeStagingDir,
+} from '../../../../main/acappella/runtime/runtime-store';
+
+/** Build the archive once per test run; it is deterministic. */
+async function buildTarball(): Promise {
+ const source = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-archive-'));
+ for (const [name, contents] of Object.entries(ARCHIVE_FILES)) {
+ const target = path.join(source, name);
+ await fs.mkdir(path.dirname(target), { recursive: true });
+ await fs.writeFile(target, contents, 'utf8');
+ }
+
+ const tarballPath = path.join(source, 'payload.tgz');
+ await tar.c(
+ { file: tarballPath, cwd: source, gzip: true },
+ Object.keys(ARCHIVE_FILES).map((name) => name)
+ );
+ tarballBytes = await fs.readFile(tarballPath);
+ tarballSha256 = createHash('sha256').update(tarballBytes).digest('hex');
+ await fs.rm(source, { recursive: true, force: true });
+}
+
+/** An injected fetch that serves the archive, or whatever bytes it is given. */
+function serve(body: Buffer, status = 200): typeof globalThis.fetch {
+ return (async () =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ body: new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(body));
+ controller.close();
+ },
+ }),
+ }) as unknown as Response) as unknown as typeof globalThis.fetch;
+}
+
+async function exists(target: string): Promise {
+ try {
+ await fs.access(target);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+beforeEach(async () => {
+ tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-userdata-'));
+ // The store reads this before it reaches for Electron, which is what lets the
+ // whole transaction run in a test with no app instance.
+ process.env.MAESTRO_USER_DATA = tempRoot;
+ await buildTarball();
+ artifact.sha256 = tarballSha256;
+ artifact.bytes = tarballBytes.length;
+});
+
+afterEach(async () => {
+ delete process.env.MAESTRO_USER_DATA;
+ await fs.rm(tempRoot, { recursive: true, force: true });
+});
+
+describe('installNativeRuntime', () => {
+ it('installs a verified payload and reports it as installed', async () => {
+ expect(await isRuntimeInstalled('onnx')).toBe(false);
+
+ const manifest = await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+
+ expect(manifest.runtimeId).toBe('onnx');
+ expect(manifest.sha256).toBe(tarballSha256);
+ expect(manifest.version).toBe('1.27.0');
+ expect(await isRuntimeInstalled('onnx')).toBe(true);
+ });
+
+ it('writes only the running platform, discarding the rest of the archive', async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ const root = runtimeDir('onnx');
+
+ expect(await exists(path.join(root, BINARY))).toBe(true);
+ expect(await exists(path.join(root, 'bin/napi-v6/darwin/arm64/libonnxruntime.1.dylib'))).toBe(
+ true
+ );
+ expect(await exists(path.join(root, ENTRY))).toBe(true);
+
+ // The point of the whole exercise.
+ expect(await exists(path.join(root, 'bin/napi-v6/win32'))).toBe(false);
+ expect(await exists(path.join(root, 'README.md'))).toBe(false);
+ });
+
+ it('removes the tarball once it has been unpacked', async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ expect(await exists(path.join(runtimeDir('onnx'), 'payload.tgz'))).toBe(false);
+ });
+
+ it('leaves no staging directory behind on success', async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ expect(await exists(runtimeStagingDir('onnx'))).toBe(false);
+ });
+
+ it('resolves the entry point to an absolute path that exists', async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ const entry = await installedRuntimeEntry('onnx');
+
+ expect(entry).toBeTruthy();
+ expect(path.isAbsolute(entry!)).toBe(true);
+ expect(await exists(entry!)).toBe(true);
+ });
+
+ it('records a footprint that is the extracted size, not the download size', async () => {
+ const manifest = await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ // Every kept file is tiny here, but the property under test is that the
+ // number is measured from disk rather than copied from the catalog.
+ expect(manifest.bytes).toBeGreaterThan(0);
+ expect(manifest.bytes).not.toBe(artifact.bytes);
+ });
+
+ it('refuses a payload whose hash does not match, and keeps nothing', async () => {
+ const tampered = Buffer.concat([tarballBytes, Buffer.from('extra')]);
+
+ await expect(
+ installNativeRuntime('onnx', { fetchImpl: serve(tampered) })
+ ).rejects.toBeInstanceOf(RuntimeHashMismatchError);
+
+ expect(await isRuntimeInstalled('onnx')).toBe(false);
+ expect(await exists(runtimeStagingDir('onnx'))).toBe(false);
+ expect(await exists(runtimeDir('onnx'))).toBe(false);
+ });
+
+ it('reports both hashes on a mismatch, so a support report can be acted on', async () => {
+ const tampered = Buffer.concat([tarballBytes, Buffer.from('extra')]);
+ const actualHash = createHash('sha256').update(tampered).digest('hex');
+
+ await expect(
+ installNativeRuntime('onnx', { fetchImpl: serve(tampered) })
+ ).rejects.toMatchObject({ expected: tarballSha256, actual: actualHash });
+ });
+
+ it('fails when the archive verifies but does not contain the promised binary', async () => {
+ // The archive is genuine and its hash is right; the artifact simply points
+ // at a file that is not in it. Without this check the install would
+ // "succeed" and die later inside a dlopen.
+ artifact.binary = 'bin/napi-v6/darwin/arm64/not-in-the-archive.node';
+
+ await expect(
+ installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) })
+ ).rejects.toBeInstanceOf(RuntimeBinaryMissingError);
+
+ expect(await isRuntimeInstalled('onnx')).toBe(false);
+ artifact.binary = BINARY;
+ });
+
+ it('fails on a non-OK response without leaving a staging directory', async () => {
+ await expect(
+ installNativeRuntime('onnx', { fetchImpl: serve(Buffer.from(''), 404) })
+ ).rejects.toThrow(/404/);
+
+ expect(await exists(runtimeStagingDir('onnx'))).toBe(false);
+ });
+
+ it('replaces a previous install rather than merging into it', async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+
+ // A file from an imaginary older version, sitting where the new payload
+ // does not put one. A merge would leave it; a replace removes it.
+ const stale = path.join(runtimeDir('onnx'), 'bin/napi-v6/darwin/arm64/old-engine.dylib');
+ await fs.writeFile(stale, 'previous version', 'utf8');
+ expect(await exists(stale)).toBe(true);
+
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ expect(await exists(stale)).toBe(false);
+ expect(await isRuntimeInstalled('onnx')).toBe(true);
+ });
+
+ it('clears the wreckage of a killed install before starting a new one', async () => {
+ const staging = runtimeStagingDir('onnx');
+ await fs.mkdir(staging, { recursive: true });
+ await fs.writeFile(path.join(staging, 'half-written.tgz'), 'junk', 'utf8');
+
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+
+ expect(await isRuntimeInstalled('onnx')).toBe(true);
+ expect(await exists(path.join(runtimeDir('onnx'), 'half-written.tgz'))).toBe(false);
+ });
+
+ it('emits progress that ends in the done phase', async () => {
+ const phases: string[] = [];
+ await installNativeRuntime('onnx', {
+ fetchImpl: serve(tarballBytes),
+ onProgress: (progress) => phases.push(progress.phase),
+ });
+
+ expect(phases).toContain('verifying');
+ expect(phases[phases.length - 1]).toBe('done');
+ });
+});
+
+describe('runtime store, after an install', () => {
+ beforeEach(async () => {
+ await installNativeRuntime('onnx', { fetchImpl: serve(tarballBytes) });
+ });
+
+ it('round-trips the manifest', async () => {
+ const manifest = await readRuntimeManifest('onnx');
+ expect(manifest).toMatchObject({
+ runtimeId: 'onnx',
+ platform: 'darwin-arm64',
+ sourceUrl: URL,
+ entry: ENTRY,
+ binary: BINARY,
+ });
+ });
+
+ it('reports not-installed once the binary is gone, manifest notwithstanding', async () => {
+ // The manifest is a claim about the past; the binary is what a dlopen
+ // needs. A store that trusted the manifest alone would send the loader at
+ // a file that is not there.
+ await fs.rm(path.join(runtimeDir('onnx'), BINARY));
+
+ expect(await isRuntimeInstalled('onnx')).toBe(false);
+ expect(await installedRuntimeEntry('onnx')).toBeNull();
+ });
+
+ it('uninstalls cleanly', async () => {
+ await removeRuntime('onnx');
+
+ expect(await isRuntimeInstalled('onnx')).toBe(false);
+ expect(await exists(runtimeDir('onnx'))).toBe(false);
+ });
+
+ it('refuses an unknown runtime id rather than turning it into a path', async () => {
+ // `runtimeDir` feeds a recursive delete, and ids arrive from IPC.
+ expect(() => runtimeDir('../../etc' as never)).toThrow(/UnknownVoiceRuntime/);
+ });
+});
diff --git a/src/__tests__/main/acappella/runtime/runtime-selftest.test.ts b/src/__tests__/main/acappella/runtime/runtime-selftest.test.ts
new file mode 100644
index 0000000000..50b2382301
--- /dev/null
+++ b/src/__tests__/main/acappella/runtime/runtime-selftest.test.ts
@@ -0,0 +1,165 @@
+/**
+ * @file runtime-selftest.test.ts
+ *
+ * The self-test is what a support report carries instead of "voice does not
+ * work". So the things worth asserting are the ones that would make that report
+ * misleading: a runtime that is not part of the build reading as broken, a
+ * failure taking the whole diagnostic down with it, or a hung load turning the
+ * diagnostic into a second copy of the bug being diagnosed.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/acappella-selftest-test' },
+ shell: { openExternal: vi.fn() },
+ systemPreferences: {
+ getMediaAccessStatus: () => 'granted',
+ askForMediaAccess: vi.fn(),
+ },
+}));
+
+import {
+ runSelfTest,
+ formatSelfTestReport,
+} from '../../../../main/acappella/runtime/runtime-selftest';
+import type { NativeRuntimeResult } from '../../../../main/acappella/runtime/native-loader';
+import type { NativeRuntimeId } from '../../../../shared/acappella/native-runtimes';
+
+/** Module surfaces the real probes accept. */
+const WORKING_MODULES: Record = {
+ llama: { getLlama: () => undefined },
+ whisper: { Whisper: function Whisper() {} },
+ onnx: { InferenceSession: {}, env: { versions: { common: '1.27.0' } } },
+};
+
+function loader(
+ behaviour: Partial>
+) {
+ return async (id: NativeRuntimeId): Promise> => {
+ const mode = behaviour[id] ?? 'pass';
+ if (mode === 'hang') return new Promise(() => undefined);
+ if (mode === 'bad-surface') return { ok: true, module: {} };
+ if (mode === 'pass') return { ok: true, module: WORKING_MODULES[id] };
+ return {
+ ok: false,
+ error: {
+ kind: 'runtime-unavailable',
+ runtimeId: id,
+ moduleId: `mock-${id}`,
+ platform: 'darwin',
+ arch: 'arm64',
+ failure: mode === 'skip' ? 'not-a-dependency' : 'load-failed',
+ message: mode === 'skip' ? 'not part of this build yet' : 'dlopen failed',
+ suggestedAction: 'do the thing',
+ detail: mode === 'skip' ? undefined : 'symbol not found',
+ },
+ };
+ };
+}
+
+const mic = () => ({ state: 'granted' as const, canPrompt: false });
+
+describe('runtime-selftest', () => {
+ it('reports every registered runtime, in registry order', async () => {
+ const report = await runSelfTest({ loadRuntime: loader({}), readMicPermission: mic });
+
+ expect(report.entries.map((entry) => entry.runtimeId)).toEqual(['llama', 'whisper', 'onnx']);
+ expect(report.entries.every((entry) => entry.status === 'pass')).toBe(true);
+ expect(report.passed).toBe(true);
+ });
+
+ it('reports a per-runtime failure without failing the others', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({ whisper: 'fail' }),
+ readMicPermission: mic,
+ });
+
+ const whisper = report.entries.find((entry) => entry.runtimeId === 'whisper')!;
+ expect(whisper.status).toBe('fail');
+ expect(whisper.failure).toBe('load-failed');
+ // The underlying cause travels: a support report with "it failed" in it is
+ // the same as no support report.
+ expect(whisper.detail).toContain('symbol not found');
+
+ expect(report.entries.find((entry) => entry.runtimeId === 'llama')?.status).toBe('pass');
+ expect(report.passed).toBe(false);
+ });
+
+ it('skips a runtime that is not part of the build rather than calling it broken', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({ whisper: 'skip' }),
+ readMicPermission: mic,
+ });
+
+ expect(report.entries.find((entry) => entry.runtimeId === 'whisper')?.status).toBe('skipped');
+ // A skip is not a failure. Reporting it as one would send someone hunting a
+ // bug that does not exist.
+ expect(report.passed).toBe(true);
+ });
+
+ it('fails a runtime that loads but has lost the API the provider calls', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({ onnx: 'bad-surface' }),
+ readMicPermission: mic,
+ });
+
+ const onnx = report.entries.find((entry) => entry.runtimeId === 'onnx')!;
+ expect(onnx.status).toBe('fail');
+ expect(onnx.failure).toBe('probe-failed');
+ expect(onnx.detail).toContain('InferenceSession');
+ });
+
+ it('reports the probe detail on success, so a version reaches the report', async () => {
+ const report = await runSelfTest({ loadRuntime: loader({}), readMicPermission: mic });
+ expect(report.entries.find((entry) => entry.runtimeId === 'onnx')?.detail).toContain('1.27.0');
+ });
+
+ it('times out instead of hanging, which is the bug it is diagnosing', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({ llama: 'hang' }),
+ readMicPermission: mic,
+ timeoutMs: 20,
+ });
+
+ const llama = report.entries.find((entry) => entry.runtimeId === 'llama')!;
+ expect(llama.status).toBe('fail');
+ expect(llama.failure).toBe('timeout');
+ });
+
+ it('records timings from the injected clock', async () => {
+ let clock = 1000;
+ const report = await runSelfTest({
+ loadRuntime: loader({}),
+ readMicPermission: mic,
+ now: () => (clock += 5),
+ });
+
+ expect(report.entries.every((entry) => entry.durationMs > 0)).toBe(true);
+ expect(report.ranAt).toBeGreaterThan(1000);
+ });
+
+ it('carries the microphone permission, because it is the other half of the diagnosis', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({}),
+ readMicPermission: () => ({ state: 'denied', canPrompt: false }),
+ });
+
+ expect(report.microphone.permission).toBe('denied');
+ // A microphone problem must be visible in the same artifact as a runtime
+ // problem, or half the reports name the wrong cause.
+ expect(formatSelfTestReport(report)).toContain('Microphone: denied');
+ });
+
+ it('formats a report that names each runtime and its verdict', async () => {
+ const report = await runSelfTest({
+ loadRuntime: loader({ whisper: 'fail' }),
+ readMicPermission: mic,
+ });
+
+ const text = formatSelfTestReport(report);
+ expect(text).toContain('FAIL');
+ expect(text).toContain('PASS');
+ expect(text).toContain('node-llama-cpp');
+ });
+});
diff --git a/src/__tests__/main/acappella/signaling.test.ts b/src/__tests__/main/acappella/signaling.test.ts
new file mode 100644
index 0000000000..2108e0b03f
--- /dev/null
+++ b/src/__tests__/main/acappella/signaling.test.ts
@@ -0,0 +1,388 @@
+/**
+ * WebRTC signaling over the authenticated WebSocket.
+ *
+ * What is under test is the authorisation boundary, not the SDP: an unpaired
+ * device, a revoked device, and a device that skipped `auth` must all be unable
+ * to reach the peer host, and a renegotiation must be able to get through
+ * because a phone changing network is the normal case rather than an attack.
+ *
+ * No network, no Fastify, no Electron: the socket is a function that appends to
+ * an array, the peer is a spy, and the pairing service writes to a temp file.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+import { PairingService } from '../../../main/acappella/pairing/pairing-service';
+import { DEFAULT_ICE_SETTINGS } from '../../../main/acappella/transport/ice-config';
+import {
+ AUTH_ATTEMPT_LIMIT,
+ OFFER_RATE_LIMIT,
+ OFFER_RATE_WINDOW_MS,
+ SignalingService,
+ parseClientMessage,
+ type SignalingPeerHost,
+ type SignalingServerMessage,
+} from '../../../main/acappella/transport/signaling';
+import { DEVICE_PROTOCOL_VERSION } from '../../../shared/acappella/device-protocol';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let dir: string;
+let pairing: PairingService;
+let peerHost: {
+ [K in keyof SignalingPeerHost]: ReturnType;
+} & SignalingPeerHost;
+let service: SignalingService;
+let sent: Record;
+let now = 5_000_000;
+
+const OFFER = { type: 'offer' as const, sdp: 'v=0\r\na=rtpmap:111 opus/48000/2\r\n' };
+
+function connect(clientId: string): void {
+ sent[clientId] = [];
+ service.register({
+ clientId,
+ send: (message) => sent[clientId].push(message),
+ remoteAddress: '192.168.1.20',
+ });
+}
+
+function last(clientId: string): SignalingServerMessage | undefined {
+ return sent[clientId][sent[clientId].length - 1];
+}
+
+async function pairDevice(name = 'Test iPhone'): Promise<{ deviceId: string; token: string }> {
+ const offer = pairing.startPairing();
+ const claim = pairing.claim({ code: offer.code, name, platform: 'ios' });
+ if (claim.status !== 'pending') throw new Error('claim failed');
+ await pairing.approve(claim.requestId);
+ const redeemed = pairing.redeem(claim.requestId);
+ if (redeemed.status !== 'approved') throw new Error('redeem failed');
+ return { deviceId: redeemed.deviceId, token: redeemed.token };
+}
+
+async function authenticate(
+ clientId: string,
+ credentials: { deviceId: string; token: string },
+ protocolVersion = DEVICE_PROTOCOL_VERSION
+): Promise {
+ await service.handleMessage(clientId, { op: 'auth', ...credentials, protocolVersion });
+}
+
+beforeEach(async () => {
+ now = 5_000_000;
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-signaling-'));
+ pairing = new PairingService({
+ filePath: path.join(dir, 'devices.json'),
+ hostSecret: 'token',
+ now: () => now,
+ });
+ peerHost = {
+ acceptOffer: vi.fn(),
+ addIceCandidate: vi.fn(),
+ closePeer: vi.fn(),
+ } as unknown as typeof peerHost;
+ sent = {};
+ service = new SignalingService({
+ pairing,
+ peerHost,
+ getIceSettings: () => DEFAULT_ICE_SETTINGS,
+ now: () => now,
+ });
+});
+
+afterEach(async () => {
+ service.dispose();
+ await fs.rm(dir, { recursive: true, force: true });
+});
+
+describe('authentication', () => {
+ it('lets a paired device in and hands it the ICE configuration', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ expect(last('c1')).toMatchObject({
+ op: 'authenticated',
+ deviceId: credentials.deviceId,
+ protocolVersion: DEVICE_PROTOCOL_VERSION,
+ iceTransportPolicy: 'all',
+ });
+ expect(service.isOnline(credentials.deviceId)).toBe(true);
+ });
+
+ it('refuses an unpaired device', async () => {
+ connect('c1');
+ await authenticate('c1', { deviceId: 'nope', token: 'nope' });
+ expect(last('c1')).toMatchObject({ op: 'auth-failed' });
+ expect(service.onlineDeviceIds()).toEqual([]);
+ });
+
+ it('refuses a revoked device', async () => {
+ const credentials = await pairDevice();
+ await pairing.revoke(credentials.deviceId);
+
+ connect('c1');
+ await authenticate('c1', credentials);
+ expect(last('c1')).toMatchObject({ op: 'auth-failed' });
+ });
+
+ it('says nothing about WHY, so the failure is not an enumeration oracle', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', { deviceId: credentials.deviceId, token: 'wrong' });
+ const wrongToken = last('c1');
+ connect('c2');
+ await authenticate('c2', { deviceId: 'no-such-device', token: 'wrong' });
+ expect(last('c2')).toEqual(wrongToken);
+ });
+
+ it('checks the protocol version before the credential', async () => {
+ connect('c1');
+ await authenticate('c1', { deviceId: 'nope', token: 'nope' }, DEVICE_PROTOCOL_VERSION + 1);
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'protocol-version' });
+ });
+
+ it('cuts off a socket that keeps guessing', async () => {
+ connect('c1');
+ for (let attempt = 0; attempt < AUTH_ATTEMPT_LIMIT; attempt += 1) {
+ await authenticate('c1', { deviceId: 'nope', token: `guess-${attempt}` });
+ }
+ await authenticate('c1', { deviceId: 'nope', token: 'one more' });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'rate-limited' });
+ });
+
+ it('displaces an older socket when a device connects again', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+ connect('c2');
+ await authenticate('c2', credentials);
+
+ expect(sent['c1'].some((message) => message.op === 'closed')).toBe(true);
+ expect(last('c2')).toMatchObject({ op: 'authenticated' });
+ // Two sockets claiming one device would both be told to hold the floor.
+ expect(service.onlineDeviceIds()).toEqual([credentials.deviceId]);
+ });
+});
+
+describe('signaling', () => {
+ it('refuses an offer from a socket that never authenticated', async () => {
+ connect('c1');
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'not-authenticated' });
+ expect(peerHost.acceptOffer).not.toHaveBeenCalled();
+ });
+
+ it('refuses a candidate from a socket that never authenticated', async () => {
+ connect('c1');
+ await service.handleMessage('c1', {
+ op: 'ice-candidate',
+ candidate: { candidate: 'candidate:1 1 udp 1 10.0.0.1 1 typ host' },
+ });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'not-authenticated' });
+ expect(peerHost.addIceCandidate).not.toHaveBeenCalled();
+ });
+
+ it('carries an offer to the peer and an answer back', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+
+ expect(peerHost.acceptOffer).toHaveBeenCalledWith(
+ expect.objectContaining({ deviceId: credentials.deviceId })
+ );
+ service.deliverAnswer(credentials.deviceId, { type: 'answer', sdp: 'v=0' });
+ expect(last('c1')).toMatchObject({ op: 'answer' });
+ });
+
+ it('trickles candidates in both directions', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ await service.handleMessage('c1', {
+ op: 'ice-candidate',
+ candidate: { candidate: 'candidate:1 1 udp 1 10.0.0.1 1 typ host', sdpMLineIndex: 0 },
+ });
+ expect(peerHost.addIceCandidate).toHaveBeenCalledWith(
+ credentials.deviceId,
+ expect.objectContaining({ sdpMLineIndex: 0 })
+ );
+
+ service.deliverIceCandidate(credentials.deviceId, { candidate: 'candidate:2' });
+ expect(last('c1')).toMatchObject({ op: 'ice-candidate' });
+ });
+
+ it('renegotiates on a network change rather than requiring a new session', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ // WiFi, then the walk out of the door, then LTE. All on the same socket,
+ // all reaching the same peer: a handover is a hiccup, not a dropped call.
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+
+ expect(peerHost.acceptOffer).toHaveBeenCalledTimes(2);
+ expect(peerHost.closePeer).not.toHaveBeenCalled();
+ expect(sent['c1'].some((message) => message.op === 'error')).toBe(false);
+ });
+});
+
+describe('offer rate limiting', () => {
+ it('allows a run of renegotiations and then refuses', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ for (let attempt = 0; attempt < OFFER_RATE_LIMIT; attempt += 1) {
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ }
+ expect(peerHost.acceptOffer).toHaveBeenCalledTimes(OFFER_RATE_LIMIT);
+
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'rate-limited' });
+ expect(peerHost.acceptOffer).toHaveBeenCalledTimes(OFFER_RATE_LIMIT);
+ });
+
+ it('slides the window rather than resetting it on a boundary', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ for (let attempt = 0; attempt < OFFER_RATE_LIMIT; attempt += 1) {
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ }
+ // Half a window later, a fixed window would have reset and let the whole
+ // allowance through again.
+ now += OFFER_RATE_WINDOW_MS / 2;
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'rate-limited' });
+
+ now += OFFER_RATE_WINDOW_MS;
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ expect(peerHost.acceptOffer).toHaveBeenCalledTimes(OFFER_RATE_LIMIT + 1);
+ });
+});
+
+describe('revocation', () => {
+ it('tears down a live signaling session the moment the pairing ends', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+ expect(service.isOnline(credentials.deviceId)).toBe(true);
+
+ await pairing.revoke(credentials.deviceId);
+
+ expect(last('c1')).toMatchObject({ op: 'closed' });
+ expect(peerHost.closePeer).toHaveBeenCalledWith(credentials.deviceId, expect.any(String));
+ expect(service.isOnline(credentials.deviceId)).toBe(false);
+ });
+
+ it('leaves the socket unable to signal afterwards', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+ await pairing.revoke(credentials.deviceId);
+
+ await service.handleMessage('c1', { op: 'offer', sdp: OFFER });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'not-authenticated' });
+ });
+});
+
+describe('pairing over the socket', () => {
+ it('walks claim, approval, and redemption', async () => {
+ const offer = pairing.startPairing();
+ connect('c1');
+ await service.handleMessage('c1', {
+ op: 'pair-claim',
+ code: offer.code,
+ name: 'New iPhone',
+ platform: 'ios',
+ });
+ const pending = last('c1');
+ expect(pending).toMatchObject({ op: 'pair-pending' });
+ if (!pending || pending.op !== 'pair-pending') return;
+
+ // Still nothing usable: the human has not approved.
+ await service.handleMessage('c1', { op: 'pair-poll', requestId: pending.requestId });
+ expect(last('c1')).toMatchObject({ op: 'pair-pending' });
+
+ await pairing.approve(pending.requestId);
+ await service.handleMessage('c1', { op: 'pair-poll', requestId: pending.requestId });
+ expect(last('c1')).toMatchObject({ op: 'pair-approved' });
+ });
+
+ it('explains a bad code rather than going quiet', async () => {
+ pairing.startPairing();
+ connect('c1');
+ await service.handleMessage('c1', {
+ op: 'pair-claim',
+ code: 'WRONG1',
+ name: 'Guess',
+ platform: 'ios',
+ });
+ expect(last('c1')).toMatchObject({ op: 'pair-rejected', reason: 'unknown-code' });
+ });
+});
+
+describe('disconnection', () => {
+ it('closes the peer when the socket goes away', async () => {
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+ service.handleDisconnect('c1');
+
+ expect(peerHost.closePeer).toHaveBeenCalledWith(credentials.deviceId, expect.any(String));
+ expect(service.isOnline(credentials.deviceId)).toBe(false);
+ });
+
+ it('reports the device offline exactly once', async () => {
+ const offline: string[] = [];
+ service = new SignalingService({
+ pairing,
+ peerHost,
+ getIceSettings: () => DEFAULT_ICE_SETTINGS,
+ now: () => now,
+ onDeviceOffline: (deviceId) => offline.push(deviceId),
+ });
+ const credentials = await pairDevice();
+ connect('c1');
+ await authenticate('c1', credentials);
+
+ service.handleDisconnect('c1');
+ service.handleDisconnect('c1');
+ expect(offline).toEqual([credentials.deviceId]);
+ });
+});
+
+describe('parseClientMessage', () => {
+ it.each([
+ ['a non-object', 42],
+ ['an unknown op', { op: 'take-over' }],
+ ['an auth with no token', { op: 'auth', deviceId: 'd' }],
+ ['an offer with no sdp', { op: 'offer', sdp: {} }],
+ ['a candidate with no candidate', { op: 'ice-candidate', candidate: {} }],
+ ])('returns null for %s', (_label, payload) => {
+ expect(parseClientMessage(payload)).toBeNull();
+ });
+
+ it('defaults a missing protocol version to one below the floor, so it is refused', () => {
+ const parsed = parseClientMessage({ op: 'auth', deviceId: 'd', token: 't' });
+ expect(parsed).toMatchObject({ op: 'auth' });
+ expect((parsed as { protocolVersion: number }).protocolVersion).toBeLessThan(1);
+ });
+
+ it('answers a malformed frame rather than throwing inside a socket handler', async () => {
+ connect('c1');
+ await service.handleMessage('c1', { op: 'nonsense' });
+ expect(last('c1')).toMatchObject({ op: 'error', code: 'malformed' });
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/agent-output-tap.test.ts b/src/__tests__/main/acappella/speech/agent-output-tap.test.ts
new file mode 100644
index 0000000000..e6a09c3744
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/agent-output-tap.test.ts
@@ -0,0 +1,178 @@
+/**
+ * @file agent-output-tap.test.ts
+ *
+ * What reaches a speaker, and what must never. The filters are asserted against
+ * the shapes agents really emit - fenced code, unified diffs, tool gutters,
+ * spinner frames, bare paths - because every one of them read aloud is a defect
+ * a user hears immediately and reports as "it read me a diff".
+ *
+ * The source is a bare EventEmitter on purpose: that is what `ProcessManager` is,
+ * and binding the suite to the real class would test the process manager.
+ */
+
+import { EventEmitter } from 'node:events';
+import { describe, it, expect, vi } from 'vitest';
+
+import {
+ AgentOutputTap,
+ type AgentOutputChunk,
+} from '../../../../main/acappella/speech/agent-output-tap';
+
+const AGENT = 'agent-1';
+const TAB = 'tab-7';
+const PROCESS_ID = `${AGENT}-ai-${TAB}`;
+
+function harness(options: { minChunkChars?: number; hangMs?: number } = {}) {
+ const source = new EventEmitter();
+ const chunks: AgentOutputChunk[] = [];
+ const timers: { fn: () => void; ms: number }[] = [];
+
+ const tap = new AgentOutputTap({
+ source,
+ onChunk: (chunk) => chunks.push(chunk),
+ minChunkChars: options.minChunkChars ?? 200,
+ hangMs: options.hangMs ?? 20_000,
+ now: () => 1_000,
+ setTimeoutFn: (fn, ms) => {
+ timers.push({ fn, ms });
+ return timers.length as unknown as ReturnType;
+ },
+ clearTimeoutFn: vi.fn(),
+ });
+ tap.watch({ agentSessionId: AGENT, tabId: TAB });
+
+ return {
+ tap,
+ chunks,
+ spoken: () => chunks.map((chunk) => chunk.text),
+ data: (text: string) => source.emit('data', PROCESS_ID, text),
+ complete: () => source.emit('query-complete', PROCESS_ID, {}),
+ fail: (message: string) => source.emit('agent-error', PROCESS_ID, { message }),
+ exit: (code: number) => source.emit('exit', PROCESS_ID, code),
+ fireHang: () => timers[timers.length - 1]?.fn(),
+ };
+}
+
+describe('AgentOutputTap', () => {
+ it('emits the finished reply as one spoken chunk', () => {
+ const h = harness();
+ h.data('Fixed the auth bug.\nIt was a stale token check.\n');
+ h.complete();
+
+ expect(h.spoken()).toEqual(['Fixed the auth bug. It was a stale token check.']);
+ expect(h.chunks[0].kind).toBe('final');
+ expect(h.chunks[0].agentSessionId).toBe(AGENT);
+ expect(h.chunks[0].tabId).toBe(TAB);
+ });
+
+ it('never speaks a code fence, even when it spans several output events', () => {
+ const h = harness();
+ h.data('Here is the fix:\n\n```ts\nconst token = read');
+ h.data('Fresh();\nreturn token;\n```\n\nThat is the whole change.\n');
+ h.complete();
+
+ expect(h.spoken().join(' ')).not.toContain('token');
+ expect(h.spoken()).toEqual(['Here is the fix:', 'That is the whole change.']);
+ });
+
+ it('drops diffs, tool gutters, bare paths, spinners, and progress readouts', () => {
+ const h = harness();
+ h.data(
+ [
+ '⏺ Bash(npm test)',
+ '⎿ 18 passed',
+ 'diff --git a/src/main/auth.ts b/src/main/auth.ts',
+ '@@ -1,4 +1,4 @@',
+ '-const token = read();',
+ '+const token = readFresh();',
+ 'src/main/auth/middleware.ts',
+ '⠋ thinking',
+ '42% done',
+ '──────────────',
+ 'The auth fix is in.',
+ '',
+ ].join('\n')
+ );
+ h.complete();
+
+ expect(h.spoken()).toEqual(['The auth fix is in.']);
+ });
+
+ it('strips ANSI rather than reading escape codes aloud', () => {
+ const h = harness();
+ h.data('[32mTests pass.[0m\n');
+ h.complete();
+
+ expect(h.spoken()).toEqual(['Tests pass.']);
+ });
+
+ it('speaks the text out of stream-json and never the tool payload around it', () => {
+ const h = harness();
+ h.data(
+ [
+ JSON.stringify({ type: 'text', text: 'Done.' }),
+ JSON.stringify({ type: 'tool_use', name: 'Bash', input: { command: 'rm -rf /' } }),
+ '',
+ ].join('\n')
+ );
+ h.complete();
+
+ expect(h.spoken().join(' ')).not.toContain('rm -rf');
+ expect(h.spoken().join(' ')).toContain('Done.');
+ });
+
+ it('cuts a completed thought loose mid-reply rather than waiting for the whole answer', () => {
+ const h = harness({ minChunkChars: 20 });
+ h.data('The first completed thought is here.\n\nStill writing the rest');
+
+ // The paragraph before the blank line is spoken while the agent types on.
+ expect(h.spoken()).toEqual(['The first completed thought is here.']);
+ expect(h.chunks[0].kind).toBe('text');
+ });
+
+ it('speaks a short honest status when the agent errors instead of going silent', () => {
+ const h = harness();
+ h.data('Working on it.\n');
+ h.fail('rate limited by the API');
+
+ expect(h.spoken()).toEqual(['Working on it.', 'It hit an error: rate limited by the API']);
+ expect(h.chunks[1].kind).toBe('status');
+ });
+
+ it('says something when the agent goes quiet, once', () => {
+ const h = harness({ hangMs: 5_000 });
+ h.data('Starting.\n');
+ h.fireHang();
+ h.fireHang();
+
+ expect(h.spoken().filter((text) => text.includes('still working'))).toEqual([
+ 'It is still working on that one.',
+ ]);
+ });
+
+ it('reports an agent that exited without answering', () => {
+ const h = harness();
+ h.exit(1);
+
+ expect(h.spoken()).toEqual(['It stopped without answering, exit code 1.']);
+ });
+
+ it('stays silent about output from a tab it is not following', () => {
+ const h = harness();
+ h.tap.unwatch({ agentSessionId: AGENT, tabId: TAB });
+ h.data('Something nobody asked to hear.\n');
+ h.complete();
+
+ expect(h.spoken()).toEqual([]);
+ expect(h.tap.isWatching).toBe(false);
+ });
+
+ it('drops every subscription on dispose', () => {
+ const h = harness();
+ h.tap.dispose();
+ h.data('Too late.\n');
+ h.complete();
+
+ expect(h.spoken()).toEqual([]);
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/background-announcer.test.ts b/src/__tests__/main/acappella/speech/background-announcer.test.ts
new file mode 100644
index 0000000000..a3d5cf66ab
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/background-announcer.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @file background-announcer.test.ts
+ *
+ * An agent finishing long after its voice turn ended. The failure this module
+ * exists to prevent is the obvious implementation: a second agent talking over
+ * the conversation you are having with the first one.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ BackgroundAnnouncer,
+ announcementText,
+} from '../../../../main/acappella/speech/background-announcer';
+import { shouldSpeakBackgroundCompletions } from '../../../../shared/acappella/announcements';
+import type { BackgroundAnnouncementSetting } from '../../../../shared/acappella/announcements';
+import type { VoiceScope } from '../../../../shared/acappella/protocol';
+
+function announcer(
+ options: {
+ scope?: VoiceScope;
+ setting?: BackgroundAnnouncementSetting;
+ foreground?: string | null;
+ queueLimit?: number;
+ } = {}
+) {
+ return new BackgroundAnnouncer({
+ getScope: () => options.scope ?? { kind: 'conductor' },
+ getSetting: () => options.setting,
+ getForegroundAgentSessionId: () => options.foreground ?? null,
+ queueLimit: options.queueLimit,
+ now: () => 1_000,
+ });
+}
+
+describe('shouldSpeakBackgroundCompletions', () => {
+ it('defaults to on for the Conductor and off inside a focused agent session', () => {
+ expect(shouldSpeakBackgroundCompletions(undefined, { kind: 'conductor' })).toBe(true);
+ expect(
+ shouldSpeakBackgroundCompletions(undefined, { kind: 'agent', sessionId: 'agent-1' })
+ ).toBe(false);
+ });
+
+ it('honours an explicit choice in either scope', () => {
+ expect(shouldSpeakBackgroundCompletions('off', { kind: 'conductor' })).toBe(false);
+ expect(shouldSpeakBackgroundCompletions('on', { kind: 'agent', sessionId: 'a' })).toBe(true);
+ });
+});
+
+describe('BackgroundAnnouncer', () => {
+ it('holds an announcement until the conversation reaches a pause', () => {
+ const queue = announcer();
+ queue.queue({ agentSessionId: 'agent-2', agentName: 'Backend', summary: 'the migration' });
+
+ expect(queue.take(false)).toBeNull();
+ expect(queue.take(true)?.text).toBe('the Backend agent finished the migration.');
+ expect(queue.take(true)).toBeNull();
+ });
+
+ it('names the source, because the listener has no tab bar to look at', () => {
+ expect(
+ announcementText({
+ agentSessionId: 'a',
+ agentName: 'Backend',
+ summary: 'Fixed the auth bug',
+ })
+ ).toBe('the Backend agent finished fixed the auth bug.');
+
+ // A name that already says "agent" is not doubled up.
+ expect(announcementText({ agentSessionId: 'a', agentName: 'Docs Agent' })).toBe(
+ 'Docs Agent finished.'
+ );
+
+ // An acronym keeps its capital.
+ expect(
+ announcementText({ agentSessionId: 'a', agentName: 'API', summary: 'API rate limiting' })
+ ).toBe('the API agent finished API rate limiting.');
+ });
+
+ it('says nothing inside a focused agent session by default', () => {
+ const queue = announcer({ scope: { kind: 'agent', sessionId: 'agent-1' } });
+
+ expect(queue.queue({ agentSessionId: 'agent-2', agentName: 'Backend' })).toBeNull();
+ expect(queue.take(true)).toBeNull();
+ });
+
+ it('does not announce the agent the current turn is already about', () => {
+ const queue = announcer({ foreground: 'agent-1' });
+
+ expect(queue.queue({ agentSessionId: 'agent-1', agentName: 'Backend' })).toBeNull();
+ expect(queue.queue({ agentSessionId: 'agent-2', agentName: 'Frontend' })).not.toBeNull();
+ });
+
+ it('drops the oldest when the backlog outgrows the limit', () => {
+ const queue = announcer({ queueLimit: 2 });
+ queue.queue({ agentSessionId: 'a1', agentName: 'One' });
+ queue.queue({ agentSessionId: 'a2', agentName: 'Two' });
+ queue.queue({ agentSessionId: 'a3', agentName: 'Three' });
+
+ expect(queue.queued.map((entry) => entry.agentName)).toEqual(['Two', 'Three']);
+ });
+
+ it('drops the backlog with the session it belonged to', () => {
+ const queue = announcer();
+ queue.queue({ agentSessionId: 'a1', agentName: 'One' });
+ queue.clear();
+
+ expect(queue.take(true)).toBeNull();
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/barge-in.test.ts b/src/__tests__/main/acappella/speech/barge-in.test.ts
new file mode 100644
index 0000000000..de27eb30ae
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/barge-in.test.ts
@@ -0,0 +1,197 @@
+/**
+ * @file barge-in.test.ts
+ *
+ * Talking over the assistant. Four things are pinned, and all four are failures
+ * you only notice by using it:
+ *
+ * - The teardown ORDER. Ducking is the only step the user hears, so it goes
+ * first; the translator stream is the step everyone forgets, so it is
+ * asserted explicitly.
+ * - The pre-roll comes with the floor, or the first word of the interruption is
+ * lost and the utterance starts mid-word.
+ * - The self-interrupt guard. Without it the assistant's own first syllable
+ * leaks past the echo canceller and it interrupts itself, which looks exactly
+ * like a crash.
+ * - Heard is not queued. The conversation memory records only what reached the
+ * speaker.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import { BargeInController } from '../../../../main/acappella/speech/barge-in';
+import type { BargeInOutcome } from '../../../../main/acappella/speech/barge-in';
+import type { SpeechRunResult } from '../../../../main/acappella/speech/speech-scheduler';
+
+const NOW = 1_000_000;
+
+function runResult(overrides: Partial = {}): SpeechRunResult {
+ return {
+ utteranceId: 'u1',
+ reason: 'interrupted',
+ spoken: ['Done, it was a stale token check.'],
+ unspoken: ['Two files changed.', 'Want the details?'],
+ capped: false,
+ ...overrides,
+ };
+}
+
+function harness(
+ options: { result?: SpeechRunResult | null; guardMs?: number; preRoll?: boolean } = {}
+) {
+ const order: string[] = [];
+ let clock = NOW;
+ const outcomes: BargeInOutcome[] = [];
+ const remembered: string[][] = [];
+
+ const controller = new BargeInController({
+ guardMs: options.guardMs ?? 250,
+ now: () => clock,
+ duck: (gain, ramp) => order.push(`duck:${gain}:${ramp}`),
+ flushPlayback: () => order.push('flush'),
+ cancelSpeech: vi.fn(() => {
+ order.push('cancel-speech');
+ return options.result === undefined ? runResult() : options.result;
+ }),
+ cancelTranslation: () => order.push('cancel-translation'),
+ toListening: (outcome) => {
+ order.push('listening');
+ outcomes.push(outcome);
+ },
+ rememberSpoken: (spoken) => remembered.push(spoken),
+ });
+
+ return {
+ controller,
+ order,
+ outcomes,
+ remembered,
+ advance: (ms: number) => {
+ clock += ms;
+ },
+ };
+}
+
+describe('BargeInController', () => {
+ it('tears down in order: duck, flush, cancel synthesis, cancel translation, listen', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ const outcome = h.controller.trigger('voice');
+
+ expect(h.order).toEqual([
+ 'duck:0.15:20',
+ 'flush',
+ 'cancel-speech',
+ 'cancel-translation',
+ 'listening',
+ ]);
+ expect(outcome?.steps).toEqual([
+ 'duck',
+ 'flush',
+ 'cancel-speech',
+ 'cancel-translation',
+ 'listening',
+ ]);
+ });
+
+ it('ducks within about 20 ms, which is the only step the user can hear', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+ h.controller.trigger();
+
+ const duck = h.order[0];
+ expect(duck.startsWith('duck:')).toBe(true);
+ const rampMs = Number(duck.split(':')[2]);
+ expect(rampMs).toBeLessThanOrEqual(20);
+ });
+
+ it('hands the floor back so the pre-roll can carry the first word of the interruption', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ h.controller.trigger('voice');
+
+ // `toListening` is the seam the audio pipeline drains its pre-roll into. It
+ // runs after the teardown, so the buffer it drains is the one that was
+ // filling while the assistant was still talking.
+ expect(h.outcomes).toHaveLength(1);
+ expect(h.order.indexOf('listening')).toBe(h.order.length - 1);
+ });
+
+ it('refuses to self-interrupt inside the guard window', () => {
+ const h = harness({ guardMs: 250 });
+ h.controller.noteSpeechStarted();
+
+ h.advance(100);
+ expect(h.controller.canInterrupt()).toBe(false);
+ expect(h.controller.trigger('voice')).toBeNull();
+ expect(h.order).toEqual([]);
+
+ h.advance(200);
+ expect(h.controller.canInterrupt()).toBe(true);
+ expect(h.controller.trigger('voice')).not.toBeNull();
+ });
+
+ it('is a no-op when nothing is speaking', () => {
+ const h = harness();
+ expect(h.controller.canInterrupt()).toBe(false);
+ expect(h.controller.trigger('client-button')).toBeNull();
+ expect(h.order).toEqual([]);
+ });
+
+ it('cannot fire twice for one speech run', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ expect(h.controller.trigger('voice')).not.toBeNull();
+ expect(h.controller.trigger('voice')).toBeNull();
+ });
+
+ it('records what was HEARD and keeps what was queued out of the memory', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ const outcome = h.controller.trigger('voice');
+
+ expect(outcome?.spoken).toEqual(['Done, it was a stale token check.']);
+ expect(outcome?.unspoken).toEqual(['Two files changed.', 'Want the details?']);
+ expect(h.remembered).toEqual([['Done, it was a stale token check.']]);
+ });
+
+ it('remembers nothing when the user cut in before a single sentence landed', () => {
+ const h = harness({ result: runResult({ spoken: [], unspoken: ['Done.'] }) });
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ const outcome = h.controller.trigger('voice');
+
+ expect(outcome?.spoken).toEqual([]);
+ expect(h.remembered).toEqual([]);
+ });
+
+ it('still tears down when the speech run had already finished on its own', () => {
+ const h = harness({ result: null });
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+
+ const outcome = h.controller.trigger('voice');
+
+ expect(outcome?.utteranceId).toBeNull();
+ expect(h.order).toContain('cancel-translation');
+ });
+
+ it('closes the window when speech ends on its own', () => {
+ const h = harness();
+ h.controller.noteSpeechStarted();
+ h.advance(500);
+ h.controller.noteSpeechEnded();
+
+ expect(h.controller.canInterrupt()).toBe(false);
+ expect(h.controller.trigger('voice')).toBeNull();
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/conversational-translator.test.ts b/src/__tests__/main/acappella/speech/conversational-translator.test.ts
new file mode 100644
index 0000000000..a612b1f32a
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/conversational-translator.test.ts
@@ -0,0 +1,243 @@
+/**
+ * @file conversational-translator.test.ts
+ *
+ * The layer that decides what a person actually hears. Four fixtures, chosen
+ * because each is a different way for a voice reply to be unusable: a four
+ * hundred line implementation summary (too long), a diff-heavy reply (unspeakable
+ * shapes), a one-word confirmation (a wasted round trip), and an error trace
+ * (the case where going silent is worst).
+ *
+ * The fake Brain deliberately returns markdown and more sentences than it was
+ * asked for, because that is what every real backend eventually does. The
+ * assertions are about what the TRANSLATOR guarantees on top of the model, not
+ * about the model's prose.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import { ConversationalTranslator } from '../../../../main/acappella/speech/conversational-translator';
+import type { BrainProvider, VoiceConverseContext } from '../../../../shared/acappella/providers';
+import type { RouteDecision } from '../../../../shared/acappella/route-decision';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const LONG_SUMMARY = [
+ '## Summary',
+ '',
+ 'I refactored the authentication middleware and threaded the refresh token through the session store.',
+ '',
+ ...Array.from(
+ { length: 380 },
+ (_, i) => `Step ${i + 1}: touched a call site and updated its test.`
+ ),
+].join('\n');
+
+const DIFF_REPLY = [
+ 'Here is the change:',
+ '',
+ '```diff',
+ '--- a/src/main/auth.ts',
+ '+++ b/src/main/auth.ts',
+ '-const token = read();',
+ '+const token = readFresh();',
+ '```',
+ '',
+ 'That is the whole fix.',
+].join('\n');
+
+const ONE_WORD = 'Yes, the tests pass.';
+
+const ERROR_TRACE = [
+ 'TypeError: cannot read property id of undefined',
+ ' at resolveSession (src/main/session.ts:42:11)',
+ ' at dispatch (src/main/dispatch.ts:11:3)',
+].join('\n');
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+interface FakeBrainOptions {
+ reply?: string;
+ deltas?: string[];
+}
+
+function fakeBrain(options: FakeBrainOptions = {}): BrainProvider & {
+ converse: ReturnType;
+ seen: VoiceConverseContext[];
+} {
+ const seen: VoiceConverseContext[] = [];
+ const reply = options.reply ?? '**Done.** I fixed the auth bug. Want the details?';
+
+ const converse = vi.fn(async (_text: string, context: VoiceConverseContext) => {
+ seen.push(context);
+ return reply;
+ });
+
+ const brain: BrainProvider & { converse: typeof converse; seen: VoiceConverseContext[] } = {
+ id: 'fake-brain',
+ label: 'Fake',
+ tier: 'mock',
+ route: async (): Promise => ({
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: '',
+ confidence: 1,
+ }),
+ converse,
+ seen,
+ };
+
+ if (options.deltas) {
+ brain.converseStream = async function* (_text: string, context: VoiceConverseContext) {
+ seen.push(context);
+ for (const delta of options.deltas ?? []) {
+ if (context.signal?.aborted) return;
+ yield delta;
+ }
+ };
+ }
+
+ return brain;
+}
+
+async function collect(iterable: AsyncIterable): Promise {
+ const out: string[] = [];
+ for await (const value of iterable) out.push(value);
+ return out;
+}
+
+function request(text: string, overrides: Partial<{ signal: AbortSignal }> = {}) {
+ return {
+ agentSessionId: 'agent-1',
+ tabId: 'tab-1',
+ text,
+ kind: 'final' as const,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+
+describe('ConversationalTranslator', () => {
+ it('turns a four hundred line summary into a short, markdown-free reply that offers detail', async () => {
+ const brain = fakeBrain({
+ reply: '**Done.** I refactored the `auth` middleware, it was a stale token check.',
+ });
+ const translator = new ConversationalTranslator({ brain });
+
+ const spoken = await collect(translator.translate(request(LONG_SUMMARY)));
+
+ expect(spoken.length).toBeLessThanOrEqual(3);
+ expect(spoken.join(' ')).not.toMatch(/[*`#|]/);
+ // The rewrite made no offer, so the translator makes one. The whole point of
+ // the layer is a headline plus a door back into the detail.
+ expect(spoken[spoken.length - 1]).toMatch(/details\?$/i);
+ expect(brain.converse).toHaveBeenCalledOnce();
+ });
+
+ it('never yields more sentences than the budget, whatever the model returns', async () => {
+ const brain = fakeBrain({ reply: 'One. Two. Three. Four. Five.' });
+ const translator = new ConversationalTranslator({ brain, maxSentences: 2 });
+
+ const spoken = await collect(translator.translate(request(DIFF_REPLY)));
+
+ // Two sentences of content. No offer: the source was short.
+ expect(spoken).toEqual(['One.', 'Two.']);
+ });
+
+ it('passes a short conversational reply through without a translation hop', async () => {
+ const brain = fakeBrain();
+ const translator = new ConversationalTranslator({ brain });
+
+ const spoken = await collect(translator.translate(request(ONE_WORD)));
+
+ expect(spoken).toEqual([ONE_WORD]);
+ expect(brain.converse).not.toHaveBeenCalled();
+ expect(translator.stats).toEqual({ translations: 0, passthroughs: 1 });
+ });
+
+ it('translates anything path-shaped or code-shaped rather than passing it through', async () => {
+ const brain = fakeBrain({ reply: 'It failed on a missing session id.' });
+ const translator = new ConversationalTranslator({ brain });
+
+ const spoken = await collect(translator.translate(request(ERROR_TRACE)));
+
+ expect(brain.converse).toHaveBeenCalledOnce();
+ expect(spoken).toEqual(['It failed on a missing session id.']);
+ expect(spoken.join(' ')).not.toContain('src/main/session.ts');
+ });
+
+ it('speaks a status chunk as-is: an error is not worth a round trip', async () => {
+ const brain = fakeBrain();
+ const translator = new ConversationalTranslator({ brain });
+
+ const spoken = await collect(
+ translator.translate({ ...request('It hit an error: rate limited.'), kind: 'status' })
+ );
+
+ expect(spoken).toEqual(['It hit an error: rate limited.']);
+ expect(brain.converse).not.toHaveBeenCalled();
+ });
+
+ it('emits sentences as they are streamed rather than waiting for the whole rewrite', async () => {
+ const brain = fakeBrain({
+ deltas: ['Done, ', 'the auth bug ', 'was a stale token check. ', 'Two files changed.'],
+ });
+ const translator = new ConversationalTranslator({ brain });
+
+ const spoken = await collect(translator.translate(request(DIFF_REPLY)));
+
+ expect(spoken).toEqual(['Done, the auth bug was a stale token check.', 'Two files changed.']);
+ // The streaming seam is used INSTEAD of the buffered one, never as well as.
+ expect(brain.converse).not.toHaveBeenCalled();
+ });
+
+ it('stops mid-stream when the turn is aborted', async () => {
+ const controller = new AbortController();
+ const brain = fakeBrain({ deltas: ['First one. ', 'Second one. ', 'Third one.'] });
+ const translator = new ConversationalTranslator({ brain, maxSentences: 5 });
+
+ const spoken: string[] = [];
+ for await (const sentence of translator.translate(
+ request(DIFF_REPLY, { signal: controller.signal })
+ )) {
+ spoken.push(sentence);
+ controller.abort();
+ }
+
+ expect(spoken).toEqual(['First one.']);
+ });
+
+ it('carries what was SPOKEN across turns, not what was queued', async () => {
+ const brain = fakeBrain({ reply: 'All good.' });
+ const translator = new ConversationalTranslator({ brain });
+
+ translator.rememberSpoken(['Done, it was a stale token check.']);
+ await collect(translator.translate(request(ERROR_TRACE)));
+
+ expect(brain.seen[0].recentSpoken).toEqual(['Done, it was a stale token check.']);
+ });
+
+ it('forgets the conversation on reset', async () => {
+ const brain = fakeBrain({ reply: 'All good.' });
+ const translator = new ConversationalTranslator({ brain });
+
+ translator.rememberSpoken(['Something earlier.']);
+ translator.reset();
+ await collect(translator.translate(request(ERROR_TRACE)));
+
+ expect(brain.seen[0].recentSpoken).toEqual([]);
+ expect(translator.memory).toEqual([]);
+ });
+
+ it('bounds the memory it carries', () => {
+ const translator = new ConversationalTranslator({ brain: fakeBrain(), memoryLimit: 2 });
+
+ translator.rememberSpoken(['One.', 'Two.', 'Three.']);
+
+ expect(translator.memory).toEqual(['Two.', 'Three.']);
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/drill-down.test.ts b/src/__tests__/main/acappella/speech/drill-down.test.ts
new file mode 100644
index 0000000000..07959feda4
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/drill-down.test.ts
@@ -0,0 +1,147 @@
+/**
+ * @file drill-down.test.ts
+ *
+ * "Tell me more", served from the retained output of the last turn.
+ *
+ * The property that matters most is the negative one: no follow-up dispatches a
+ * new agent turn. Re-asking would cost a full round trip AND would answer a
+ * different question, because the agent has moved on since the sentence the user
+ * is asking about.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import {
+ DetailBuffer,
+ detectDrillDownIntent,
+ speakPath,
+} from '../../../../main/acappella/speech/drill-down';
+
+const DETAIL = [
+ 'I refactored the authentication middleware in src/main/auth/middleware.ts.',
+ 'The stale token check was in the refresh path.',
+ 'Six call sites needed updating.',
+ 'All eighteen tests pass.',
+ 'Nothing else changed.',
+].join(' ');
+
+function buffer(): DetailBuffer {
+ const detail = new DetailBuffer({ sentencesPerSlice: 2 });
+ detail.record({
+ agentSessionId: 'agent-1',
+ tabId: 'tab-7',
+ detail: DETAIL,
+ spoken: ['Done, the auth bug was a stale token check.'],
+ });
+ return detail;
+}
+
+describe('detectDrillDownIntent', () => {
+ it('recognises each follow-up', () => {
+ expect(detectDrillDownIntent('tell me more')).toBe('more');
+ expect(detectDrillDownIntent('go on')).toBe('more');
+ expect(detectDrillDownIntent('what else?')).toBe('more');
+ expect(detectDrillDownIntent('say that again')).toBe('repeat');
+ expect(detectDrillDownIntent('read that again')).toBe('repeat');
+ expect(detectDrillDownIntent('what was the file?')).toBe('file');
+ expect(detectDrillDownIntent('which file was it')).toBe('file');
+ expect(detectDrillDownIntent('show me that')).toBe('show');
+ expect(detectDrillDownIntent('pull up the diff')).toBe('show');
+ });
+
+ it('leaves a real request alone', () => {
+ expect(detectDrillDownIntent('run the tests again')).toBeNull();
+ expect(detectDrillDownIntent('open a new tab for the migration')).toBeNull();
+ expect(detectDrillDownIntent('show me the backlog for next sprint')).toBeNull();
+ expect(detectDrillDownIntent('')).toBeNull();
+ });
+
+ it('prefers show over file, because a request to LOOK is answered on screen', () => {
+ expect(detectDrillDownIntent('show me the file')).toBe('show');
+ });
+});
+
+describe('DetailBuffer', () => {
+ it('serves successive slices of detail without a new agent turn', () => {
+ const detail = buffer();
+
+ expect(detail.serve('more')).toEqual({
+ kind: 'speak',
+ text: 'I refactored the authentication middleware in src/main/auth/middleware.ts. The stale token check was in the refresh path.',
+ });
+ expect(detail.serve('more')).toEqual({
+ kind: 'speak',
+ text: 'Six call sites needed updating. All eighteen tests pass.',
+ });
+ expect(detail.serve('more')).toEqual({ kind: 'speak', text: 'Nothing else changed.' });
+ expect(detail.serve('more')).toEqual({ kind: 'speak', text: "That's everything it said." });
+ });
+
+ it('repeats exactly what was said, not a fresh rewrite of it', () => {
+ const detail = buffer();
+ detail.noteSpoken(['Want the details?']);
+
+ expect(detail.serve('repeat')).toEqual({
+ kind: 'speak',
+ text: 'Done, the auth bug was a stale token check. Want the details?',
+ });
+ });
+
+ it('names the file the way a person would say it', () => {
+ expect(buffer().serve('file')).toEqual({ kind: 'speak', text: 'It was middleware dot ts.' });
+ });
+
+ it('says so when no file was named rather than inventing one', () => {
+ const detail = new DetailBuffer();
+ detail.record({
+ agentSessionId: 'agent-1',
+ tabId: 'tab-7',
+ detail: 'It all worked out fine in the end.',
+ spoken: [],
+ });
+
+ expect(detail.serve('file')).toEqual({ kind: 'speak', text: 'It did not name a file.' });
+ });
+
+ it('answers "show me" on screen and says nothing at all', () => {
+ expect(buffer().serve('show')).toEqual({
+ kind: 'focus',
+ agentSessionId: 'agent-1',
+ tabId: 'tab-7',
+ path: 'src/main/auth/middleware.ts',
+ });
+ });
+
+ it('has nothing to serve before a turn is recorded, and nothing after it is cleared', () => {
+ const detail = new DetailBuffer();
+ expect(detail.hasTurn).toBe(false);
+ expect(detail.serve('more')).toEqual({ kind: 'none' });
+
+ const live = buffer();
+ live.clear();
+ expect(live.serve('repeat')).toEqual({ kind: 'none' });
+ });
+
+ it('rewinds the read cursor when a new turn replaces the old one', () => {
+ const detail = buffer();
+ detail.serve('more');
+ detail.record({
+ agentSessionId: 'agent-2',
+ tabId: 'tab-9',
+ detail: 'A brand new answer entirely.',
+ spoken: [],
+ });
+
+ expect(detail.serve('more')).toEqual({ kind: 'speak', text: 'A brand new answer entirely.' });
+ });
+});
+
+describe('speakPath', () => {
+ it('says the basename and its extension, never the directories', () => {
+ expect(speakPath('src/main/acappella/speech/speech-scheduler.ts')).toBe(
+ 'speech-scheduler dot ts'
+ );
+ expect(speakPath('C:\\Users\\dev\\project\\index.ts')).toBe('index dot ts');
+ expect(speakPath('Makefile')).toBe('Makefile');
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/send-phrase.test.ts b/src/__tests__/main/acappella/speech/send-phrase.test.ts
new file mode 100644
index 0000000000..f070630164
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/send-phrase.test.ts
@@ -0,0 +1,95 @@
+/**
+ * @file send-phrase.test.ts
+ *
+ * The spoken "that's it, go". Two things have to be exactly right: it fires only
+ * at the END of a turn (mid-sentence agreement is not a send signal), and what
+ * survives is the request WITHOUT the phrase, because that string becomes the
+ * prompt an agent receives.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ DEFAULT_SEND_PHRASES,
+ matchSendPhrase,
+ normalisePhrase,
+} from '../../../../main/acappella/speech/send-phrase';
+
+describe('matchSendPhrase', () => {
+ it('strips the phrase and keeps the request', () => {
+ const match = matchSendPhrase('fix the auth bug, good to go');
+
+ expect(match?.text).toBe('fix the auth bug');
+ expect(match?.phrase).toBe('good to go');
+ });
+
+ it('returns empty text when the whole turn was the signal', () => {
+ // The common case: you pause, then say it on its own. Everything already
+ // buffered is the request.
+ const match = matchSendPhrase("That's it.");
+
+ expect(match).not.toBeNull();
+ expect(match?.text).toBe('');
+ });
+
+ it('does NOT fire mid-sentence', () => {
+ // Agreeing and then carrying on is not a send signal. Only position tells
+ // these apart, which is why the match is anchored to the end.
+ expect(matchSendPhrase("that's it, the bug is in the auth module")).toBeNull();
+ expect(matchSendPhrase('go ahead and look at the tests')).toBeNull();
+ });
+
+ it('ignores casing, punctuation and apostrophes the recogniser chose', () => {
+ // Whether a transcript contains an apostrophe is a property of the engine,
+ // not of the speaker.
+ expect(matchSendPhrase('run the tests. Thats it')?.text).toBe('run the tests');
+ expect(matchSendPhrase("run the tests, that's IT!")?.text).toBe('run the tests');
+ });
+
+ it('keeps the original wording of the request', () => {
+ // Normalising is for MATCHING. The surviving text becomes a prompt, so the
+ // user's capitals and punctuation have to come through untouched.
+ const match = matchSendPhrase('Look at OAuth in the API repo, send it');
+
+ expect(match?.text).toBe('Look at OAuth in the API repo');
+ });
+
+ it('prefers the longest phrase when two could match', () => {
+ const match = matchSendPhrase('do the thing, go ahead', ['ahead', 'go ahead']);
+
+ expect(match?.phrase).toBe('go ahead');
+ expect(match?.text).toBe('do the thing');
+ });
+
+ it('returns null when nothing matches', () => {
+ expect(matchSendPhrase('fix the auth bug')).toBeNull();
+ });
+
+ it('returns null for an empty utterance', () => {
+ expect(matchSendPhrase(' ')).toBeNull();
+ });
+
+ it('ignores a blank configured phrase rather than matching everything', () => {
+ // An empty phrase normalises to '', which every string ends with.
+ expect(matchSendPhrase('anything at all', ['', ' '])).toBeNull();
+ });
+
+ it('handles a symbol between the request and the phrase', () => {
+ expect(matchSendPhrase('fix the bug -- good to go')?.text).toBe('fix the bug');
+ });
+
+ it('ships phrases that are natural to say', () => {
+ for (const phrase of DEFAULT_SEND_PHRASES) {
+ expect(matchSendPhrase(`do the work, ${phrase}`)?.text).toBe('do the work');
+ }
+ });
+});
+
+describe('normalisePhrase', () => {
+ it('collapses casing, punctuation and spacing', () => {
+ expect(normalisePhrase(" That's IT! ")).toBe('thats it');
+ });
+
+ it('reduces a string with nothing speakable in it to empty', () => {
+ expect(normalisePhrase('!!! ...')).toBe('');
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/speech-scheduler.test.ts b/src/__tests__/main/acappella/speech/speech-scheduler.test.ts
new file mode 100644
index 0000000000..8e240f3024
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/speech-scheduler.test.ts
@@ -0,0 +1,377 @@
+/**
+ * @file speech-scheduler.test.ts
+ *
+ * The queue between the translator and the speaker. Four things are pinned here
+ * because each of them is silently wrong in a way a listener notices and a log
+ * does not:
+ *
+ * - Segmentation against the strings agents actually write (`v1.2.3`,
+ * `src/main/index.ts`, `99.5`, `e.g.`). A splitter that gets one wrong reads
+ * half a sentence and then stops.
+ * - No gap between sentences: the next one is synthesized while the current one
+ * is still being delivered.
+ * - The length cap wraps up out loud instead of cutting off, because a stop
+ * with no explanation reads as a crash to someone with no screen.
+ * - `interrupted` is not `completed`. The conversation memory is built on that
+ * difference.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+
+import {
+ SpeechScheduler,
+ type SpeechRunResult,
+} from '../../../../main/acappella/speech/speech-scheduler';
+import { splitIntoSpokenSentences } from '../../../../shared/acappella/sentences';
+import type { TtsChunk, TtsProvider } from '../../../../shared/acappella/providers';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+interface FakeTts extends TtsProvider {
+ /** Sentences the provider was asked to synthesize, in order. */
+ requested: string[];
+ /** Sentences whose synthesis has been started but not resolved. */
+ release: (sentence: string) => void;
+ cancel: ReturnType void>>;
+}
+
+/**
+ * A provider whose synthesis can be held open, which is the only way to observe
+ * lookahead: with instant synthesis every schedule looks gapless.
+ */
+function fakeTts(options: { manual?: boolean } = {}): FakeTts {
+ const requested: string[] = [];
+ const gates = new Map void>();
+ let cancelled = false;
+
+ const provider: FakeTts = {
+ id: 'fake-tts',
+ label: 'Fake',
+ tier: 'mock',
+ requested,
+ cancel: vi.fn(() => {
+ cancelled = true;
+ for (const open of gates.values()) open();
+ gates.clear();
+ }),
+ release: (sentence: string) => {
+ gates.get(sentence)?.();
+ gates.delete(sentence);
+ },
+ speak: async function* (text: string, speakOptions): AsyncIterable {
+ requested.push(text);
+ if (options.manual) {
+ await new Promise((resolve) => gates.set(text, resolve));
+ }
+ if (cancelled) return;
+ yield {
+ utteranceId: speakOptions.utteranceId,
+ index: requested.length - 1,
+ text,
+ format: 'none',
+ audio: null,
+ };
+ },
+ };
+
+ return provider;
+}
+
+interface Harness {
+ scheduler: SpeechScheduler;
+ tts: FakeTts;
+ starts: { utteranceId: string; sentenceCount: number; streaming: boolean }[];
+ sentences: { index: number; text: string }[];
+ ends: SpeechRunResult[];
+ chunks: TtsChunk[];
+}
+
+function harness(
+ overrides: Partial[0]> = {}
+): Harness {
+ const tts = (overrides.tts as FakeTts) ?? fakeTts();
+ const starts: Harness['starts'] = [];
+ const sentences: Harness['sentences'] = [];
+ const ends: SpeechRunResult[] = [];
+ const chunks: TtsChunk[] = [];
+
+ const scheduler = new SpeechScheduler({
+ tts,
+ onStart: (event) => starts.push(event),
+ onSentence: (event) => sentences.push({ index: event.index, text: event.text }),
+ onEnd: (result) => ends.push(result),
+ onChunk: (chunk) => chunks.push(chunk),
+ ...overrides,
+ });
+
+ return { scheduler, tts, starts, sentences, ends, chunks };
+}
+
+/** Let the scheduler's worker run to its next await. */
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+// ---------------------------------------------------------------------------
+
+describe('sentence segmentation', () => {
+ it('does not split on abbreviations, decimals, version numbers, or file extensions', () => {
+ expect(splitIntoSpokenSentences('Coverage is 99.5 percent now.')).toEqual([
+ 'Coverage is 99.5 percent now.',
+ ]);
+ expect(splitIntoSpokenSentences('Bumped it to v1.2.3 this morning.')).toEqual([
+ 'Bumped it to v1.2.3 this morning.',
+ ]);
+ expect(splitIntoSpokenSentences('The fix is in src/main/index.ts near the top.')).toEqual([
+ 'The fix is in src/main/index.ts near the top.',
+ ]);
+ expect(splitIntoSpokenSentences('Check the store, e.g. the session one.')).toEqual([
+ 'Check the store, e.g. the session one.',
+ ]);
+ expect(splitIntoSpokenSentences('It ships in the U.S. only.')).toEqual([
+ 'It ships in the U.S. only.',
+ ]);
+ });
+
+ it('still splits after an acronym, which is what agents write constantly', () => {
+ expect(splitIntoSpokenSentences('Fixed the API. Then I ran the tests.')).toEqual([
+ 'Fixed the API.',
+ 'Then I ran the tests.',
+ ]);
+ });
+
+ it('splits at the end of a sentence that finishes on a file name', () => {
+ expect(splitIntoSpokenSentences('It is in src/main/index.ts. Two lines changed.')).toEqual([
+ 'It is in src/main/index.ts.',
+ 'Two lines changed.',
+ ]);
+ });
+});
+
+describe('SpeechScheduler', () => {
+ it('announces a streaming run with the count it has, not a count it invented', async () => {
+ const h = harness();
+ h.scheduler.begin('u1');
+ await settle();
+
+ expect(h.starts).toEqual([
+ { utteranceId: 'u1', sentenceCount: 0, streaming: true, ttsProviderId: 'fake-tts' },
+ ]);
+ });
+
+ it('speaks pushed text sentence by sentence and reports each one before its audio', async () => {
+ const h = harness();
+ h.scheduler.begin('u1');
+ h.scheduler.push('Done, it was a stale token check. ');
+ h.scheduler.push('Two files changed.');
+ h.scheduler.close();
+
+ const result = await h.scheduler.drained();
+
+ expect(h.sentences.map((s) => s.text)).toEqual([
+ 'Done, it was a stale token check.',
+ 'Two files changed.',
+ ]);
+ expect(h.chunks.map((c) => c.text)).toEqual(h.sentences.map((s) => s.text));
+ expect(result?.reason).toBe('completed');
+ expect(result?.spoken).toHaveLength(2);
+ expect(result?.unspoken).toEqual([]);
+ });
+
+ it('holds an unterminated tail rather than speaking half a sentence', async () => {
+ const h = harness();
+ h.scheduler.begin('u1');
+ h.scheduler.push('The file is index');
+ await settle();
+
+ expect(h.tts.requested).toEqual([]);
+
+ // The rest of the token arrives and proves it was never a boundary.
+ h.scheduler.push('.ts and it is fine.');
+ h.scheduler.close();
+ await h.scheduler.drained();
+
+ expect(h.tts.requested).toEqual(['The file is index .ts and it is fine.']);
+ });
+
+ it('synthesizes the next sentence while the current one is still playing', async () => {
+ const tts = fakeTts({ manual: true });
+ const h = harness({ tts, lookahead: 1 });
+
+ h.scheduler.begin('u1');
+ h.scheduler.push('One. Two. Three. ');
+ await settle();
+
+ // Sentence two is already being synthesized while sentence one is still open
+ // and has not been delivered. That overlap is what removes the gap.
+ expect(h.tts.requested).toEqual(['One.', 'Two.']);
+ expect(h.sentences).toEqual([]);
+ // Not the whole reply, though: a barge-in during sentence one must not throw
+ // away three sentences of paid-for audio.
+ expect(h.tts.requested).not.toContain('Three.');
+
+ tts.release('One.');
+ await settle();
+ expect(h.sentences.map((s) => s.text)).toEqual(['One.']);
+ expect(h.tts.requested).toEqual(['One.', 'Two.', 'Three.']);
+
+ tts.release('Two.');
+ tts.release('Three.');
+ h.scheduler.close();
+ await h.scheduler.drained();
+ expect(h.sentences.map((s) => s.text)).toEqual(['One.', 'Two.', 'Three.']);
+ });
+
+ it('wraps up out loud when the per-turn cap is reached', async () => {
+ const h = harness({ maxSentencesPerTurn: 2 });
+ h.scheduler.begin('u1');
+ h.scheduler.push('One. Two. Three. Four. ');
+ h.scheduler.close();
+
+ const result = await h.scheduler.drained();
+
+ expect(h.sentences.map((s) => s.text)).toEqual([
+ 'One.',
+ 'Two.',
+ "There's more, ask me for the details.",
+ ]);
+ expect(result?.capped).toBe(true);
+ expect(result?.reason).toBe('completed');
+ // The sentences it never got to are recorded as unheard, so nothing claims
+ // the user was told about them.
+ expect(result?.unspoken).toEqual(['Three.', 'Four.']);
+ });
+
+ it('does not promise details that do not exist when the run ends exactly on the cap', async () => {
+ const h = harness({ maxSentencesPerTurn: 2 });
+ h.scheduler.begin('u1');
+ h.scheduler.push('One. Two. ');
+ h.scheduler.close();
+
+ const result = await h.scheduler.drained();
+
+ expect(h.sentences.map((s) => s.text)).toEqual(['One.', 'Two.']);
+ expect(result?.capped).toBe(false);
+ });
+
+ it('reports interrupted, not completed, and separates what was heard from what was not', async () => {
+ const tts = fakeTts({ manual: true });
+ const h = harness({ tts });
+
+ h.scheduler.begin('u1');
+ h.scheduler.push('First. Second. Third. ');
+ await settle();
+ tts.release('First.');
+ await settle();
+
+ // Mid-synthesis of the second sentence, with the third still queued.
+ const result = h.scheduler.cancel('interrupted');
+
+ expect(result?.reason).toBe('interrupted');
+ expect(result?.spoken).toEqual(['First.']);
+ expect(result?.unspoken).toEqual(['Second.', 'Third.']);
+ expect(h.tts.cancel).toHaveBeenCalledOnce();
+ });
+
+ it('emits exactly one speak-end however the run ends', async () => {
+ const h = harness();
+ h.scheduler.begin('u1');
+ h.scheduler.push('Only one. ');
+ h.scheduler.close();
+ await h.scheduler.drained();
+
+ expect(h.scheduler.cancel('interrupted')).toBeNull();
+ expect(h.ends).toHaveLength(1);
+ });
+
+ it('drops sentences that arrive after the run ended', async () => {
+ const h = harness();
+ h.scheduler.begin('u1');
+ h.scheduler.push('Done. ');
+ h.scheduler.close();
+ await h.scheduler.drained();
+
+ h.scheduler.push('Too late.');
+ await settle();
+
+ expect(h.tts.requested).toEqual(['Done.']);
+ });
+
+ it('reports an error end reason when the provider fails mid-run', async () => {
+ const failing: TtsProvider = {
+ id: 'broken-tts',
+ label: 'Broken',
+ tier: 'mock',
+ cancel: vi.fn(),
+ // eslint-disable-next-line require-yield
+ speak: async function* (): AsyncIterable {
+ throw new Error('synthesis failed');
+ },
+ };
+ const errors: Error[] = [];
+ const h = harness({ tts: failing as FakeTts, onError: (error) => errors.push(error) });
+
+ h.scheduler.begin('u1');
+ h.scheduler.push('Anything. ');
+ const result = await h.scheduler.drained();
+
+ expect(result?.reason).toBe('error');
+ expect(errors.map((error) => error.message)).toEqual(['synthesis failed']);
+ });
+});
+
+describe('live voice and rate', () => {
+ /** A provider that records the options each sentence was synthesized with. */
+ function recordingTts(): TtsProvider & { options: Array<{ voiceId?: string; rate?: number }> } {
+ const options: Array<{ voiceId?: string; rate?: number }> = [];
+ return {
+ id: 'recording-tts',
+ label: 'Recording',
+ tier: 'mock',
+ options,
+ cancel: vi.fn(),
+ speak: async function* (text: string, speakOptions): AsyncIterable {
+ options.push({ voiceId: speakOptions.voiceId, rate: speakOptions.rate });
+ yield {
+ utteranceId: speakOptions.utteranceId,
+ index: options.length - 1,
+ text,
+ format: 'none',
+ audio: null,
+ };
+ },
+ };
+ }
+
+ it('reads the voice and rate fresh for every sentence', async () => {
+ // This is what makes the Settings sliders apply to the NEXT SENTENCE
+ // rather than the next session. Reading them once at construction would
+ // pin a whole conversation to whatever was configured when it started.
+ const tts = recordingTts();
+ let current = { voiceId: 'alloy', rate: 1 };
+ const h = harness({ tts: tts as never, speechOptions: () => current });
+
+ h.scheduler.begin('u1');
+ h.scheduler.push('One sentence. ');
+ await settle();
+ current = { voiceId: 'nova', rate: 1.25 };
+ h.scheduler.push('Two sentence. ');
+ h.scheduler.close();
+ await h.scheduler.drained();
+
+ expect(tts.options[0]).toEqual({ voiceId: 'alloy', rate: 1 });
+ expect(tts.options[1]).toEqual({ voiceId: 'nova', rate: 1.25 });
+ });
+
+ it('passes nothing when no getter was supplied, leaving the provider its default', async () => {
+ const tts = recordingTts();
+ const h = harness({ tts: tts as never });
+
+ h.scheduler.begin('u1');
+ h.scheduler.push('One sentence. ');
+ h.scheduler.close();
+ await h.scheduler.drained();
+
+ expect(tts.options[0]).toEqual({ voiceId: undefined, rate: undefined });
+ });
+});
diff --git a/src/__tests__/main/acappella/speech/utterance-composer.test.ts b/src/__tests__/main/acappella/speech/utterance-composer.test.ts
new file mode 100644
index 0000000000..1a0ca33dbc
--- /dev/null
+++ b/src/__tests__/main/acappella/speech/utterance-composer.test.ts
@@ -0,0 +1,364 @@
+/**
+ * @file utterance-composer.test.ts
+ *
+ * The thing this component exists to stop: a pause mid-sentence becoming two
+ * separate requests to an agent. Every test here is about where the boundary of
+ * one thought is drawn, so they all run on fake timers - the boundary is a
+ * duration, and asserting it against the wall clock would be a flaky way to
+ * describe a deterministic rule.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ UtteranceComposer,
+ type ComposedUtterance,
+} from '../../../../main/acappella/speech/utterance-composer';
+
+const SETTLE = 900;
+
+function makeComposer(
+ overrides: { settleMs?: number; maxHoldMs?: number; sendPhrases?: readonly string[] } = {}
+) {
+ const settled: ComposedUtterance[] = [];
+ const composing: string[] = [];
+ const composer = new UtteranceComposer({
+ settleMs: overrides.settleMs ?? SETTLE,
+ maxHoldMs: overrides.maxHoldMs ?? 30_000,
+ sendPhrases: overrides.sendPhrases,
+ onSettled: (utterance) => settled.push(utterance),
+ onComposing: (text) => composing.push(text),
+ });
+ return { composer, settled, composing };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+describe('UtteranceComposer', () => {
+ it('joins fragments separated by a pause into one request', () => {
+ // The bug, in one test: a person pausing to think used to produce two
+ // dispatches, and the agent answered half a sentence.
+ const { composer, settled } = makeComposer();
+
+ composer.add('look at the auth module', 0.9);
+ vi.advanceTimersByTime(SETTLE - 100);
+ composer.add('and tell me why the refresh is failing', 0.8);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].text).toBe('look at the auth module and tell me why the refresh is failing');
+ expect(settled[0].fragments).toBe(2);
+ });
+
+ it('dispatches nothing while the user is still talking', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('first', 1);
+ vi.advanceTimersByTime(SETTLE - 1);
+
+ expect(settled).toEqual([]);
+ });
+
+ it('settles a complete thought once the pause is long enough', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('open the auth tab', 1);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].fragments).toBe(1);
+ });
+
+ it('reports the worst confidence of the parts, not the average', () => {
+ // The whole thing is dispatched as one request, so it is only as reliable
+ // as its most doubtful fragment; averaging lets a clear part vouch for a
+ // mumbled one.
+ const { composer, settled } = makeComposer();
+
+ composer.add('clear part', 0.95);
+ composer.add('mumbled part', 0.4);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled[0].confidence).toBe(0.4);
+ });
+
+ it('sums the spoken duration across fragments', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('one', 1, 1_000);
+ composer.add('two', 1, 500);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled[0].durationMs).toBe(1_500);
+ });
+
+ it('emits the growing text so the transcript does not blank mid-thought', () => {
+ const { composer, composing } = makeComposer();
+
+ composer.add('look at', 1);
+ composer.add('the auth module', 1);
+
+ expect(composing).toEqual(['look at', 'look at the auth module']);
+ });
+
+ it('ignores an empty final rather than restarting the clock for silence', () => {
+ const { composer, settled, composing } = makeComposer();
+
+ composer.add(' ', 1);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled).toEqual([]);
+ expect(composing).toEqual([]);
+ });
+
+ it('dispatches on arrival when composing is switched off', () => {
+ // settleMs 0 is the pre-composer behaviour, kept reachable for anyone who
+ // wants the old snappiness back.
+ const { composer, settled } = makeComposer({ settleMs: 0 });
+
+ composer.add('go', 1);
+
+ expect(settled).toHaveLength(1);
+ });
+
+ /**
+ * The spoken Enter key. A settle timer is a guess at when someone stopped
+ * talking; a phrase is them saying so, which is why the timer becomes a
+ * backstop rather than the mechanism once these exist.
+ */
+ describe('a send phrase ends dictation immediately', () => {
+ it('sends without waiting for the settle timer', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add('fix the auth bug, good to go', 1);
+
+ // No timer advanced: the whole point is not waiting.
+ expect(settled).toHaveLength(1);
+ expect(settled[0].sentBy).toBe('good to go');
+ });
+
+ it('strips the phrase, so the agent gets the request and not the signal', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add('fix the auth bug, good to go', 1);
+
+ expect(settled[0].text).toBe('fix the auth bug');
+ });
+
+ it('sends everything buffered when the phrase is a turn of its own', () => {
+ // The way it is actually said: you talk, you pause, then you say it.
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add('look at the auth module', 1);
+ composer.add('and say why the refresh fails', 1);
+ composer.add("that's it", 1);
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].text).toBe('look at the auth module and say why the refresh fails');
+ expect(settled[0].fragments).toBe(2);
+ });
+
+ it('ignores the signal when there is no request to send', () => {
+ // A send phrase with an empty buffer would otherwise dispatch an empty
+ // prompt, which is worse than doing nothing.
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add('good to go', 1);
+
+ expect(settled).toEqual([]);
+ expect(composer.composing).toBe(false);
+ });
+
+ it('does not fire on a phrase said mid-sentence', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add("that's it exactly, the auth module is the problem", 1);
+
+ expect(settled).toEqual([]);
+ expect(composer.composing).toBe(true);
+ });
+
+ it('leaves the timer as the backstop when nothing is said', () => {
+ // Forgetting the phrase must not mean the request never goes.
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.add('fix the auth bug', 1);
+ vi.advanceTimersByTime(30_000);
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].sentBy).toBeUndefined();
+ });
+
+ it('can be switched off entirely', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000, sendPhrases: [] });
+
+ composer.add('fix the auth bug, good to go', 1);
+
+ expect(settled).toEqual([]);
+ });
+ });
+
+ /**
+ * Letting go of a push-to-talk key. The recogniser is flushed at the same
+ * moment, and its final can land either side of the release, which is the
+ * entire reason this is not just "settle now".
+ */
+ describe('a release gesture', () => {
+ it('sends what is buffered without waiting for the timer', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+ composer.add('fix the auth bug', 1);
+
+ composer.armImmediateSettle();
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].text).toBe('fix the auth bug');
+ expect(settled[0].sentBy).toBe('release');
+ });
+
+ it('waits for a flushed tail that has not arrived yet', () => {
+ // The failure this exists to prevent: settling only the current buffer
+ // sends the sentence minus its last few words.
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.armImmediateSettle();
+ expect(settled).toEqual([]);
+
+ composer.add('the last few words', 1);
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].text).toBe('the last few words');
+ });
+
+ it('includes the tail with what came before it', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+ composer.add('look at the auth module', 1);
+
+ // Buffer settles on the release, then the flushed tail arrives as its own
+ // thought - the honest reading of words spoken after "I am done".
+ composer.armImmediateSettle();
+ composer.add('and the refresh path', 1);
+
+ expect(settled.map((entry) => entry.text)).toEqual([
+ 'look at the auth module',
+ 'and the refresh path',
+ ]);
+ });
+
+ it('does nothing when nothing was ever said', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.armImmediateSettle();
+ vi.advanceTimersByTime(60_000);
+
+ expect(settled).toEqual([]);
+ });
+
+ it('is cleared by a cancel, so a closed floor cannot settle later', () => {
+ const { composer, settled } = makeComposer({ settleMs: 30_000 });
+
+ composer.armImmediateSettle();
+ composer.cancel();
+ composer.add('spoken into a dead session', 1);
+
+ expect(settled).toEqual([]);
+ });
+ });
+
+ describe('ending a thought by decree', () => {
+ it('flush settles immediately', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('half a sentence', 1);
+ composer.flush();
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].text).toBe('half a sentence');
+ });
+
+ it('flush on an empty buffer dispatches nothing', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.flush();
+
+ expect(settled).toEqual([]);
+ });
+
+ it('cancel drops the thought and the timer with it', () => {
+ // The floor closing must not leave a fragment to be dispatched into a
+ // session nobody is in.
+ const { composer, settled } = makeComposer();
+
+ composer.add('abandoned', 1);
+ composer.cancel();
+ vi.advanceTimersByTime(SETTLE * 5);
+
+ expect(settled).toEqual([]);
+ expect(composer.composing).toBe(false);
+ });
+ });
+
+ it('stops holding at the cap, so a noisy room cannot wait forever', () => {
+ // The backstop, not a normal path: fragments arriving faster than the settle
+ // window would otherwise never let the thought finish.
+ const { composer, settled } = makeComposer({ maxHoldMs: 3_000 });
+
+ for (let elapsed = 0; elapsed < 5_000; elapsed += 300) {
+ composer.add('noise', 1);
+ vi.advanceTimersByTime(300);
+ }
+
+ expect(settled.length).toBeGreaterThan(0);
+ });
+
+ it('never lets the cap undercut the wait it is backstopping', () => {
+ // A 30 s hold under a 30 s cap fires the cap first and splits the thought,
+ // because the cap starts on the first fragment while the settle restarts on
+ // every one.
+ const { composer, settled } = makeComposer({ settleMs: 30_000, maxHoldMs: 30_000 });
+
+ composer.add('still talking', 1);
+ vi.advanceTimersByTime(29_000);
+ composer.add('and still going', 1);
+ vi.advanceTimersByTime(29_000);
+
+ expect(settled).toEqual([]);
+ expect(composer.composing).toBe(true);
+ });
+
+ it('does not restart the cap on every fragment', () => {
+ // Restarting it would make the backstop unreachable in exactly the case it
+ // exists for - continuous fragments. Gaps are shorter than the settle, so
+ // the settle keeps restarting and only the cap can end this.
+ const { composer, settled } = makeComposer({ settleMs: 200, maxHoldMs: 800 });
+
+ for (let i = 0; i < 6; i += 1) {
+ composer.add('still going', 1);
+ vi.advanceTimersByTime(150);
+ }
+
+ expect(settled).toHaveLength(1);
+ expect(settled[0].fragments).toBeGreaterThan(1);
+ });
+
+ it('starts a fresh thought after one settles', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('first thought', 1);
+ vi.advanceTimersByTime(SETTLE);
+ composer.add('second thought', 1);
+ vi.advanceTimersByTime(SETTLE);
+
+ expect(settled.map((entry) => entry.text)).toEqual(['first thought', 'second thought']);
+ });
+
+ it('dispose stops it settling anything afterwards', () => {
+ const { composer, settled } = makeComposer();
+
+ composer.add('pending', 1);
+ composer.dispose();
+ composer.add('more', 1);
+ vi.advanceTimersByTime(SETTLE * 5);
+
+ expect(settled).toEqual([]);
+ });
+});
diff --git a/src/__tests__/main/acappella/telemetry/turn-metrics.test.ts b/src/__tests__/main/acappella/telemetry/turn-metrics.test.ts
new file mode 100644
index 0000000000..a7e4568d95
--- /dev/null
+++ b/src/__tests__/main/acappella/telemetry/turn-metrics.test.ts
@@ -0,0 +1,161 @@
+/**
+ * @file turn-metrics.test.ts
+ *
+ * The instrumentation that answers "voice feels slow" with a hop instead of a
+ * shrug. Two things worth pinning down: a milestone is stamped once (a second
+ * partial is not the first partial), and the breakdown reports time spent IN each
+ * hop rather than time since the turn began, because the first form is actionable
+ * and the second is arithmetic homework.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import {
+ TurnTimer,
+ describeTurn,
+ formatTurnBreakdown,
+ lastTurn,
+ recordTurn,
+ resetTurnMetrics,
+ turnHistory,
+} from '../../../../main/acappella/telemetry/turn-metrics';
+
+const CONFIG = {
+ pipeline: 'cascade' as const,
+ providerIds: { stt: 'whisper-local', tts: 'kokoro-local', brain: 'qwen3-local' },
+};
+
+/** A clock the test drives, so no span depends on how fast the machine is. */
+function clock(): { now: () => number; advance: (ms: number) => void } {
+ let time = 1_000;
+ return {
+ now: () => time,
+ advance: (ms) => {
+ time += ms;
+ },
+ };
+}
+
+beforeEach(() => {
+ resetTurnMetrics();
+});
+
+describe('TurnTimer', () => {
+ it('records every hop from the moment speech ended', () => {
+ const time = clock();
+ const timer = new TurnTimer('turn-1', CONFIG, time.now);
+
+ time.advance(300);
+ timer.mark('firstPartial');
+ time.advance(200);
+ timer.mark('finalTranscript');
+ time.advance(400);
+ timer.mark('routeDecision');
+ time.advance(1_500);
+ timer.mark('agentFirstToken');
+ time.advance(250);
+ timer.mark('firstSpokenSentence');
+
+ const metrics = timer.finish();
+ expect(metrics.spans).toEqual({
+ firstPartial: 300,
+ finalTranscript: 500,
+ routeDecision: 900,
+ agentFirstToken: 2_400,
+ firstSpokenSentence: 2_650,
+ total: 2_650,
+ });
+ });
+
+ it('keeps the FIRST stamp for a span', () => {
+ const time = clock();
+ const timer = new TurnTimer('turn-1', CONFIG, time.now);
+
+ timer.mark('firstPartial');
+ time.advance(500);
+ // A later partial is not the first one; overwriting would quietly turn this
+ // into a most-recent-event log.
+ timer.mark('firstPartial');
+
+ expect(timer.finish().spans.firstPartial).toBe(0);
+ });
+
+ it('leaves a milestone that never happened absent', () => {
+ const timer = new TurnTimer('turn-1', CONFIG, clock().now);
+ timer.mark('finalTranscript');
+
+ const metrics = timer.finish();
+ // A turn with a transcript and no spoken sentence failed somewhere specific,
+ // and the gap is the evidence.
+ expect(metrics.spans.firstSpokenSentence).toBeUndefined();
+ });
+});
+
+describe('describeTurn', () => {
+ it('reports time spent in each hop, with the total as the whole turn', () => {
+ const breakdown = describeTurn({
+ turnId: 'turn-1',
+ startedAt: 0,
+ configuration: CONFIG,
+ spans: { firstPartial: 300, finalTranscript: 500, total: 900 },
+ });
+
+ expect(breakdown.deltas.map((delta) => [delta.span, delta.ms])).toEqual([
+ ['firstPartial', 300],
+ ['finalTranscript', 200],
+ ['total', 900],
+ ]);
+ });
+
+ it('formats spans with the shared duration helper', () => {
+ const breakdown = describeTurn({
+ turnId: 'turn-1',
+ startedAt: 0,
+ configuration: CONFIG,
+ spans: { firstPartial: 1_500, total: 1_500 },
+ });
+
+ expect(breakdown.deltas[0].formatted).toBe('1.50s');
+ });
+});
+
+describe('the rolling history', () => {
+ it('remembers the last turn and names the configuration it ran on', () => {
+ recordTurn({ turnId: 'a', startedAt: 0, configuration: CONFIG, spans: { total: 100 } });
+ recordTurn({ turnId: 'b', startedAt: 0, configuration: CONFIG, spans: { total: 200 } });
+
+ expect(lastTurn()?.turnId).toBe('b');
+ expect(turnHistory()).toHaveLength(2);
+ });
+
+ it('is empty before anything has been said', () => {
+ expect(lastTurn()).toBeNull();
+ });
+
+ it('caps what it retains', () => {
+ for (let index = 0; index < 50; index++) {
+ recordTurn({
+ turnId: `turn-${index}`,
+ startedAt: 0,
+ configuration: CONFIG,
+ spans: { total: index },
+ });
+ }
+
+ expect(turnHistory().length).toBeLessThanOrEqual(20);
+ expect(lastTurn()?.turnId).toBe('turn-49');
+ });
+
+ it('formats a breakdown that names the providers it was measured on', () => {
+ recordTurn({
+ turnId: 'a',
+ startedAt: 0,
+ configuration: CONFIG,
+ spans: { firstPartial: 300, total: 300 },
+ });
+
+ const text = formatTurnBreakdown(lastTurn()!);
+ expect(text).toContain('whisper-local');
+ expect(text).toContain('Speech end to first partial');
+ });
+});
diff --git a/src/__tests__/main/acappella/transport-stand-down.test.ts b/src/__tests__/main/acappella/transport-stand-down.test.ts
new file mode 100644
index 0000000000..df869861d4
--- /dev/null
+++ b/src/__tests__/main/acappella/transport-stand-down.test.ts
@@ -0,0 +1,154 @@
+/**
+ * The transport's Encore-flag behaviour.
+ *
+ * A Cappella is off by default, and "off" has to mean the same thing to every
+ * surface that reads the flag. The transport owns the two resources a user most
+ * expects that switch to control - a Bonjour advert broadcasting this machine's
+ * name and port, and live connections from paired phones - so it is the one that
+ * has to stand down.
+ *
+ * Contracts defended:
+ * - `standDown()` takes the advert down, cancels any half-finished pairing, and
+ * drops live connections.
+ * - It does NOT revoke anything. Switching the feature off says "stop", not
+ * "forget my phone"; re-pairing a device because a checkbox was toggled is a
+ * punishment for reading the settings screen.
+ * - It is not `dispose()`. The transport is constructed once per process at
+ * handler registration, so a teardown here would mean switching the feature
+ * back on did nothing until the next restart.
+ * - `featureEnabled()` is read from settings on every call, so the signaling
+ * adapter and the IPC handlers cannot disagree about it.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as fs from 'fs/promises';
+import * as os from 'os';
+import * as path from 'path';
+
+import { ACappellaTransport } from '../../../main/acappella/transport';
+import type { WebRtcHostCommand } from '../../../shared/acappella/webrtc-host';
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+let dir: string;
+let settings: Record;
+let hostCommands: WebRtcHostCommand[];
+let transport: ACappellaTransport;
+
+function createTransport(): ACappellaTransport {
+ return new ACappellaTransport({
+ settingsStore: {
+ get: (key: string, defaultValue?: unknown) => settings[key] ?? defaultValue,
+ },
+ userDataPath: dir,
+ sendToAudioHost: (command) => hostCommands.push(command),
+ acquireFloor: () => {
+ throw new Error('no floor in this test');
+ },
+ getSession: () => null,
+ getServerToken: () => 'server-token',
+ getServerPort: () => 4123,
+ getAppVersion: () => '0.0.0-test',
+ getMachineName: () => 'Test Machine',
+ });
+}
+
+beforeEach(async () => {
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acappella-transport-'));
+ settings = { encoreFeatures: { aCappella: true } };
+ hostCommands = [];
+ transport = createTransport();
+});
+
+afterEach(async () => {
+ transport.dispose();
+ await fs.rm(dir, { recursive: true, force: true });
+});
+
+describe('ACappellaTransport.featureEnabled', () => {
+ it('mirrors the Encore flag, read fresh each time', () => {
+ expect(transport.featureEnabled()).toBe(true);
+
+ settings.encoreFeatures = { aCappella: false };
+ expect(transport.featureEnabled()).toBe(false);
+
+ settings.encoreFeatures = { aCappella: true };
+ expect(transport.featureEnabled()).toBe(true);
+ });
+
+ it('is false when no flags have ever been written', () => {
+ settings = {};
+ expect(transport.featureEnabled()).toBe(false);
+ });
+});
+
+describe('ACappellaTransport.standDown', () => {
+ it('takes the advert down', async () => {
+ // No mDNS responder is available in a test process, so the advert reports
+ // itself unavailable rather than advertising. What matters is the transition:
+ // the service must end up disabled, which is the state that has actually
+ // released the responder.
+ await transport.discovery.start();
+ const stop = vi.spyOn(transport.discovery, 'stop');
+
+ transport.standDown();
+ await transport.discovery.stop();
+
+ expect(stop).toHaveBeenCalled();
+ expect(transport.discoveryStatus()).toEqual({ state: 'disabled' });
+ });
+
+ it('cancels a pairing window that was open', () => {
+ expect(transport.startPairing()).not.toBeNull();
+ expect(transport.currentPairingPayload()).not.toBeNull();
+
+ transport.standDown();
+
+ // A code that outlives the switch is a code somebody can still redeem
+ // against a feature its owner believes is off.
+ expect(transport.currentPairingPayload()).toBeNull();
+ });
+
+ it('disconnects live devices without revoking any of them', async () => {
+ const offer = transport.startPairing();
+ const claim = transport.pairing.claim({
+ code: offer!.code,
+ name: 'Test iPhone',
+ platform: 'ios',
+ });
+ expect(claim.status).toBe('pending');
+ if (claim.status !== 'pending') return;
+ await transport.pairing.approve(claim.requestId);
+
+ const disconnect = vi.spyOn(transport, 'disconnectAll');
+ transport.standDown();
+
+ expect(disconnect).toHaveBeenCalled();
+
+ const devices = await transport.listDevices();
+ expect(devices).toHaveLength(1);
+ expect(devices[0].revokedAt).toBeNull();
+ expect(devices[0].online).toBe(false);
+ });
+
+ it('leaves the transport able to serve again when the feature comes back on', async () => {
+ transport.standDown();
+
+ // The reason this is standDown() and not dispose(): the transport is built
+ // once per process, so a user who toggles the feature off and on again must
+ // not need a restart to pair.
+ const payload = transport.startPairing();
+ expect(payload).not.toBeNull();
+ expect(payload?.kind).toBe('maestro-acappella');
+ await expect(transport.listDevices()).resolves.toEqual([]);
+ });
+
+ it('is safe to call twice, and with nothing running', () => {
+ expect(() => {
+ transport.standDown();
+ transport.standDown();
+ }).not.toThrow();
+ });
+});
diff --git a/src/__tests__/main/acappella/voice-session-service.test.ts b/src/__tests__/main/acappella/voice-session-service.test.ts
new file mode 100644
index 0000000000..2436bf0fb4
--- /dev/null
+++ b/src/__tests__/main/acappella/voice-session-service.test.ts
@@ -0,0 +1,1998 @@
+/**
+ * @file voice-session-service.test.ts
+ *
+ * Unit tests for the headless voice session service: the wake -> listen ->
+ * transcribe -> route -> dispatch -> speak pipeline, monotonic `seq`, the
+ * barge-in / stop distinction, and the classified failure modes.
+ *
+ * No electron, no providers, no timers: a fake trio drives the pipeline
+ * synchronously so every assertion is about the service's own sequencing.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../main/utils/logger', () => ({
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+/**
+ * Every transition the service takes, recorded in order.
+ *
+ * `transition()` is private and most edges are invisible in the event stream
+ * (`transcribing` and `routing` emit nothing of their own), so the only honest
+ * way to prove which edges the pipeline actually walks is to wrap the shared
+ * assertion the service routes all of them through. The wrapper still delegates,
+ * so an illegal edge throws exactly as it would in production.
+ */
+const transitionLog = vi.hoisted(() => ({ edges: [] as string[] }));
+
+vi.mock('../../../shared/acappella/session-state', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ assertVoiceStateTransition: (from: string, to: string) => {
+ transitionLog.edges.push(`${from} -> ${to}`);
+ actual.assertVoiceStateTransition(from as VoiceSessionState, to as VoiceSessionState);
+ },
+ };
+});
+
+import { captureException } from '../../../main/utils/sentry';
+import {
+ VoiceSessionService,
+ VoiceDispatchError,
+ type AgentReplyStream,
+ type VoiceDispatchResult,
+ type VoiceFocusTarget,
+ type VoiceRouteExecutor,
+} from '../../../main/acappella/voice-session-service';
+import type { AgentOutputChunk } from '../../../main/acappella/speech';
+import type { BackgroundAnnouncementSetting } from '../../../shared/acappella/announcements';
+import type { RosterAgent, VoiceEvent, VoiceEventType } from '../../../shared/acappella/protocol';
+import type {
+ BrainProvider,
+ SttCallbacks,
+ SttProvider,
+ TtsChunk,
+ TtsProvider,
+ VoiceConverseContext,
+ VoiceProviderTrio,
+ VoiceRouteContext,
+} from '../../../shared/acappella/providers';
+import type { VoiceReadiness, VoiceSlotReadiness } from '../../../shared/acappella/readiness';
+import type { RouteDecision } from '../../../shared/acappella/route-decision';
+import { splitIntoSpokenSentences } from '../../../shared/acappella/sentences';
+import {
+ InvalidVoiceStateTransitionError,
+ VOICE_STATE_TRANSITIONS,
+ type VoiceSessionState,
+} from '../../../shared/acappella/session-state';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/** Text-in STT: two partials then a final, exactly like the mock tier will. */
+class FakeStt implements SttProvider {
+ readonly id = 'fake-stt';
+ readonly label = 'Fake STT';
+ readonly tier = 'mock' as const;
+ readonly sampleRate = 16_000;
+ readonly acceptsAudio = false;
+
+ callbacks: SttCallbacks | null = null;
+ started = false;
+ stopped = false;
+ /** When set, `start()` rejects with it. */
+ startError: Error | null = null;
+
+ async start(callbacks: SttCallbacks): Promise {
+ if (this.startError) throw this.startError;
+ this.callbacks = callbacks;
+ this.started = true;
+ }
+ feed(): void {}
+ async flush(): Promise {}
+ async stop(): Promise {
+ this.stopped = true;
+ }
+ injectUtterance(text: string): void {
+ this.callbacks?.onPartial(text.slice(0, Math.ceil(text.length / 3)), 0.3);
+ this.callbacks?.onPartial(text.slice(0, Math.ceil((text.length * 2) / 3)), 0.7);
+ this.callbacks?.onFinal(text, 0.95);
+ }
+}
+
+class FakeBrain implements BrainProvider {
+ readonly id = 'fake-brain';
+ readonly label = 'Fake Brain';
+ readonly tier = 'mock' as const;
+
+ decision: RouteDecision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'hello',
+ confidence: 0.9,
+ };
+ spoken = 'All done. Two files changed.';
+ routeError: Error | null = null;
+ /** When set, `route()` parks here, so a test can act while the brain thinks. */
+ routeGate: Promise | null = null;
+
+ /** Every context the brain was routed with, so a test can read the conversation. */
+ contexts: VoiceRouteContext[] = [];
+ async route(_input: string, context: VoiceRouteContext): Promise {
+ this.contexts.push(context);
+ if (this.routeGate) await this.routeGate;
+ if (this.routeError) throw this.routeError;
+ return this.decision;
+ }
+ async converse(_agentText: string, _context: VoiceConverseContext): Promise {
+ return this.spoken;
+ }
+}
+
+/**
+ * Yields one chunk per sentence, checking a cancel flag between them so
+ * `cancel()` cuts the run off rather than draining it.
+ */
+class FakeTts implements TtsProvider {
+ readonly id = 'fake-tts';
+ readonly label = 'Fake TTS';
+ readonly tier = 'mock' as const;
+
+ cancelled = false;
+ /** Called after each chunk, so a test can interrupt mid-run. */
+ onChunk: (() => void) | null = null;
+ /** Thrown from inside the iterator, the way a streaming cloud voice fails. */
+ speakError: Error | null = null;
+
+ speak(text: string, options: { utteranceId: string }): AsyncIterable {
+ this.cancelled = false;
+ const sentences = splitIntoSpokenSentences(text);
+ const self = this;
+ return {
+ async *[Symbol.asyncIterator]() {
+ for (let index = 0; index < sentences.length; index++) {
+ if (self.cancelled) return;
+ if (self.speakError) throw self.speakError;
+ yield {
+ utteranceId: options.utteranceId,
+ index,
+ text: sentences[index],
+ format: 'none' as const,
+ audio: null,
+ };
+ self.onChunk?.();
+ }
+ },
+ };
+ }
+ cancel(): void {
+ this.cancelled = true;
+ }
+}
+
+/**
+ * An agent reply the translator will NOT pass through.
+ *
+ * Two lines, so it is markdown-shaped by the passthrough test's own rule, which
+ * is what sends it to the Brain and makes `FakeBrain.spoken` the thing that gets
+ * said. A short single-line reply is passed through untouched on purpose - that
+ * is the "no translation hop for `yes, done`" behaviour, covered on its own in
+ * conversational-translator.test.ts and again below.
+ */
+const AGENT_REPLY = 'Rewrote the stale token check in the auth middleware.\nTwo files changed.';
+
+function makeRoster(): RosterAgent[] {
+ return [
+ {
+ sessionId: 'agent-backend',
+ name: 'Backend',
+ agentType: 'claude-code',
+ cwd: '/repo',
+ tabs: [{ id: 'tab-1', name: 'Auth', lastActiveAt: 1 }],
+ },
+ ];
+}
+
+interface Harness {
+ service: VoiceSessionService;
+ stt: FakeStt;
+ tts: FakeTts;
+ brain: FakeBrain;
+ events: VoiceEvent[];
+ types: () => VoiceEventType[];
+ executor: ReturnType;
+}
+
+function makeHarness(
+ overrides: {
+ executeRoute?: VoiceRouteExecutor;
+ onSpeechChunk?: (chunk: TtsChunk) => void;
+ checkReadiness?: () => VoiceReadiness | Promise;
+ agentReplyStream?: AgentReplyStream;
+ focusTarget?: VoiceFocusTarget;
+ getBackgroundAnnouncementSetting?: () => BackgroundAnnouncementSetting | undefined;
+ bargeInGuardMs?: number;
+ getUtteranceComposerConfig?: () => { settleMs?: number; maxHoldMs?: number };
+ getConversationalMode?: () => boolean;
+ } = {}
+): Harness {
+ const stt = new FakeStt();
+ const tts = new FakeTts();
+ const brain = new FakeBrain();
+ const providers: VoiceProviderTrio = { stt, tts, brain };
+
+ const dispatchResult: VoiceDispatchResult = {
+ agentSessionId: 'agent-backend',
+ agentName: 'Backend',
+ tabId: 'tab-1',
+ action: 'focused',
+ promptSent: true,
+ };
+ const executor = vi.fn(async () => dispatchResult);
+
+ const service = new VoiceSessionService({
+ providers,
+ getRoster: () => makeRoster(),
+ executeRoute: overrides.executeRoute ?? (executor as unknown as VoiceRouteExecutor),
+ onSpeechChunk: overrides.onSpeechChunk,
+ checkReadiness: overrides.checkReadiness,
+ agentReplyStream: overrides.agentReplyStream,
+ focusTarget: overrides.focusTarget,
+ getBackgroundAnnouncementSetting: overrides.getBackgroundAnnouncementSetting,
+ // Off unless a test asks for it. The guard is real-time dead time after
+ // speech starts, so leaving it on would make every barge-in assertion here a
+ // race against the wall clock; it has its own tests below.
+ bargeInGuardMs: overrides.bargeInGuardMs ?? 0,
+ // Off unless a test asks: `FakeStt` is text-in, so the composer is bypassed
+ // for every existing test here, which is the production rule too.
+ getUtteranceComposerConfig: overrides.getUtteranceComposerConfig,
+ // Off unless a test asks, which is the shipped default: every utterance is
+ // a command until someone turns conversation on.
+ getConversationalMode: overrides.getConversationalMode,
+ });
+
+ const events: VoiceEvent[] = [];
+ service.subscribe((event) => events.push(event));
+
+ return { service, stt, tts, brain, events, types: () => events.map((e) => e.type), executor };
+}
+
+/** Start a session and drain the events emitted by startup. */
+async function start(h: Harness): Promise {
+ await h.service.startSession({ scope: { kind: 'conductor' }, source: 'hotkey' });
+ h.events.length = 0;
+ takeEdges();
+}
+
+/** Read and clear the recorded transitions. */
+function takeEdges(): string[] {
+ return transitionLog.edges.splice(0, transitionLog.edges.length);
+}
+
+function deferred(): { promise: Promise; resolve: () => void } {
+ let resolve!: () => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('VoiceSessionService lifecycle', () => {
+ let h: Harness;
+
+ beforeEach(() => {
+ h = makeHarness();
+ });
+
+ it('starts idle with no session', () => {
+ const snapshot = h.service.getSnapshot();
+ expect(snapshot.state).toBe('idle');
+ expect(snapshot.sessionId).toBeNull();
+ expect(snapshot.providerIds).toEqual({ stt: 'fake-stt', tts: 'fake-tts', brain: 'fake-brain' });
+ });
+
+ it('wakes into listening and announces the provider', async () => {
+ const snapshot = await h.service.startSession({
+ scope: { kind: 'agent', sessionId: 'agent-backend' },
+ source: 'wake-word',
+ });
+
+ expect(snapshot.state).toBe('listening');
+ expect(snapshot.sessionId).toBeTruthy();
+ expect(h.stt.started).toBe(true);
+ expect(h.types()).toEqual(['wake', 'listen-start', 'agent-roster']);
+
+ const listenStart = h.events[1];
+ expect(listenStart.type === 'listen-start' && listenStart.sttProviderId).toBe('fake-stt');
+ });
+
+ /**
+ * Conversational mode: the Conductor answers instead of dispatching, until a
+ * doable thing has been described. Off by default, because it changes what a
+ * spoken sentence means.
+ */
+ describe('talking it through before dispatching', () => {
+ function chatHarness() {
+ return makeHarness({ getConversationalMode: () => true });
+ }
+
+ it('speaks a reply and touches no agent', async () => {
+ const chat = chatHarness();
+ chat.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: '',
+ confidence: 0.3,
+ reply: 'Is that failing on every load, or only the second?',
+ };
+ await start(chat);
+
+ chat.service.submitUtterance('the token refresh keeps breaking');
+ await vi.waitFor(() => expect(chat.types()).toContain('speak-start'));
+
+ expect(chat.executor).not.toHaveBeenCalled();
+ expect(chat.types()).not.toContain('dispatch');
+ });
+
+ it('hands the floor straight back, so the exchange continues', async () => {
+ const chat = chatHarness();
+ chat.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: '',
+ confidence: 0.3,
+ reply: 'Which repository is that in?',
+ };
+ await start(chat);
+
+ chat.service.submitUtterance('something is wrong with auth');
+ await vi.waitFor(() => expect(chat.service.getState()).toBe('listening'));
+
+ expect(chat.service.getState()).toBe('listening');
+ });
+
+ it('shows the Brain what has already been said', async () => {
+ // Without the history the second sentence is routed on its own, which is
+ // how a two-word answer becomes a tab called "the second one".
+ const chat = chatHarness();
+ chat.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: '',
+ confidence: 0.3,
+ reply: 'Every load, or the second?',
+ };
+ await start(chat);
+
+ chat.service.submitUtterance('the refresh keeps failing');
+ await vi.waitFor(() => expect(chat.service.getState()).toBe('listening'));
+ chat.service.submitUtterance('the second one');
+ await vi.waitFor(() => expect(chat.brain.contexts.length).toBeGreaterThan(1));
+
+ const latest = chat.brain.contexts[chat.brain.contexts.length - 1];
+ expect(latest.conversational).toBe(true);
+ expect(latest.conversation?.map((turn) => turn.text)).toEqual([
+ 'the refresh keeps failing',
+ 'Every load, or the second?',
+ 'the second one',
+ ]);
+ });
+
+ it('dispatches once the Brain stops replying', async () => {
+ const chat = chatHarness();
+ chat.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: '',
+ confidence: 0.3,
+ reply: 'Which part is failing?',
+ };
+ await start(chat);
+ chat.service.submitUtterance('auth is broken');
+ await vi.waitFor(() => expect(chat.service.getState()).toBe('listening'));
+
+ // The Brain decides there is a task now.
+ chat.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'Fix the token refresh on the second load.',
+ confidence: 0.9,
+ };
+ chat.service.submitUtterance('the token refresh on the second load');
+ await vi.waitFor(() => expect(chat.types()).toContain('dispatch'));
+
+ expect(chat.executor).toHaveBeenCalledTimes(1);
+ });
+
+ it('forgets the discussion once it has been sent', async () => {
+ // Otherwise the next request arrives wearing the last one's context.
+ const chat = chatHarness();
+ await start(chat);
+
+ chat.service.submitUtterance('run the tests');
+ await vi.waitFor(() => expect(chat.types()).toContain('dispatch'));
+ chat.service.submitUtterance('now the other thing');
+ await vi.waitFor(() => expect(chat.brain.contexts.length).toBeGreaterThan(1));
+
+ const latest = chat.brain.contexts[chat.brain.contexts.length - 1];
+ expect(latest.conversation?.map((turn) => turn.text)).toEqual(['now the other thing']);
+ });
+
+ it('routes every utterance when the mode is off', async () => {
+ // The shipped default, and the behaviour anyone already using voice has.
+ await start(h);
+
+ h.service.submitUtterance('run the tests');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ expect(h.executor).toHaveBeenCalledTimes(1);
+ expect(h.brain.contexts[0].conversational).toBe(false);
+ expect(h.brain.contexts[0].conversation).toBeUndefined();
+ });
+ });
+
+ /**
+ * A pause mid-sentence is not the end of a request. Before the composer, the
+ * 700 ms endpoint in `audio/vad.ts` turned one thought into two dispatches and
+ * the agent answered half a sentence.
+ */
+ describe('assembling one thought from several fragments', () => {
+ /** A recogniser that listens to a room, which is what triggers composing. */
+ function listeningHarness(settleMs: number) {
+ const h = makeHarness({ getUtteranceComposerConfig: () => ({ settleMs }) });
+ (h.stt as unknown as { acceptsAudio: boolean }).acceptsAudio = true;
+ return h;
+ }
+
+ /** A recogniser final, bypassing the fake provider's own partial timing. */
+ function final(h: Harness, text: string) {
+ (h.stt.callbacks as { onFinal: (t: string, c: number) => void }).onFinal(text, 0.9);
+ }
+
+ it('dispatches ONE request for a sentence said in two halves', async () => {
+ const h2 = listeningHarness(40);
+ await start(h2);
+
+ final(h2, 'look at the auth module');
+ final(h2, 'and say why the refresh is failing');
+ await vi.waitFor(() => expect(h2.types()).toContain('dispatch'));
+
+ expect(h2.executor).toHaveBeenCalledTimes(1);
+ const finals = h2.events.filter((e) => e.type === 'final-transcript');
+ expect(finals).toHaveLength(1);
+ expect(finals[0].type === 'final-transcript' && finals[0].text).toBe(
+ 'look at the auth module and say why the refresh is failing'
+ );
+ });
+
+ it('leaves a text-in provider alone, since its utterance was already whole', async () => {
+ // `FakeStt` is text-in. Someone who typed a message and pressed send has
+ // delimited it themselves; holding it would be latency for nothing.
+ await start(h);
+
+ h.service.submitUtterance('do the thing');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ expect(h.executor).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends on a release without waiting out the hold', async () => {
+ // Letting go of a push-to-talk key. The recogniser flush is a separate
+ // call; without `endUtteranceNow` the resulting final would be buffered
+ // and sit out a settle window the user already answered.
+ const h2 = listeningHarness(30_000);
+ await start(h2);
+
+ final(h2, 'fix the auth bug');
+ h2.service.endUtteranceNow();
+ await vi.waitFor(() => expect(h2.types()).toContain('dispatch'));
+
+ expect(h2.executor).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a half-collected thought when the floor closes', async () => {
+ // Otherwise it settles into a session nobody is in.
+ const h2 = listeningHarness(10_000);
+ await start(h2);
+
+ final(h2, 'half a sentence');
+ await h2.service.stopSession('user');
+ await new Promise((resolve) => setTimeout(resolve, 30));
+
+ expect(h2.executor).not.toHaveBeenCalled();
+ });
+ });
+
+ /**
+ * A recogniser that hears but does not transcribe (the microphone check) is a
+ * meter, not a voice. Routing its measurement onward sent live agents prompts
+ * like "Echo utterance 4: 1.5s of speech." and billed the user for the reply.
+ */
+ describe('a diagnostic recogniser', () => {
+ function deafHarness() {
+ const h = makeHarness();
+ // Same object the service holds, so the flag is read at turn time.
+ (h.stt as unknown as { transcribesSpeech: boolean }).transcribesSpeech = false;
+ return h;
+ }
+
+ it('shows the transcript but never dispatches it to an agent', async () => {
+ const deaf = deafHarness();
+ await start(deaf);
+
+ deaf.service.submitUtterance('Echo utterance 1: 1.5s of speech.');
+ // Waiting for the FLOOR TO REOPEN, not for the transcript: the transcript
+ // is emitted before routing would happen, so asserting on it would pass
+ // even with the guard removed. Reopening is the end of the whole turn.
+ await vi.waitFor(() => expect(deaf.service.getState()).toBe('listening'));
+
+ // The user still sees proof the microphone works...
+ expect(deaf.types()).toContain('final-transcript');
+ // ...and no agent is told anything.
+ expect(deaf.executor).not.toHaveBeenCalled();
+ expect(deaf.types()).not.toContain('route-decision');
+ expect(deaf.types()).not.toContain('dispatch');
+ });
+
+ it('reopens the floor, so the meter keeps working turn after turn', async () => {
+ const deaf = deafHarness();
+ await start(deaf);
+
+ deaf.service.submitUtterance('Echo utterance 1: 1.5s of speech.');
+ await vi.waitFor(() => expect(deaf.service.getState()).toBe('listening'));
+
+ expect(deaf.service.getState()).toBe('listening');
+ });
+
+ it('still routes for an ordinary recogniser', async () => {
+ // The guard is one flag; a regression that read it backwards would make
+ // every real provider silent, which is worse than the bug it fixes.
+ await start(h);
+
+ h.service.submitUtterance('do the thing');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ expect(h.executor).toHaveBeenCalled();
+ });
+ });
+
+ it('stamps every event with the session id and a monotonic seq', async () => {
+ await start(h);
+ h.service.submitUtterance('do the thing');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ const sessionId = h.service.getSnapshot().sessionId;
+ expect(new Set(h.events.map((e) => e.sessionId))).toEqual(new Set([sessionId]));
+
+ const seqs = h.events.map((e) => e.seq);
+ expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
+ expect(new Set(seqs).size).toBe(seqs.length);
+ });
+
+ it('resets seq when a new session replaces the old one', async () => {
+ await start(h);
+ await h.service.stopSession('user');
+ h.events.length = 0;
+
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(h.events[0].seq).toBe(1);
+ });
+
+ it('replaces a running session rather than stacking one', async () => {
+ await start(h);
+ const first = h.service.getSnapshot().sessionId;
+
+ await h.service.startSession({ scope: { kind: 'agent', sessionId: 'agent-backend' } });
+ expect(h.service.getSnapshot().sessionId).not.toBe(first);
+ expect(h.service.getState()).toBe('listening');
+ expect(h.types()).toContain('listen-stop');
+ });
+
+ it('stops back to idle and releases the provider', async () => {
+ await start(h);
+ await h.service.stopSession('user');
+
+ expect(h.service.getState()).toBe('idle');
+ expect(h.service.getSnapshot().sessionId).toBeNull();
+ expect(h.stt.stopped).toBe(true);
+ expect(h.types()).toEqual(['listen-stop']);
+ });
+
+ it('ignores stopSession when already idle', async () => {
+ await h.service.stopSession('user');
+ expect(h.events).toHaveLength(0);
+ });
+});
+
+describe('VoiceSessionService turn pipeline', () => {
+ let h: Harness;
+
+ beforeEach(async () => {
+ h = makeHarness();
+ await start(h);
+ });
+
+ it('runs partials, final, routing, and dispatch in order', async () => {
+ h.service.submitUtterance('open the auth tab');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ expect(h.types()).toEqual([
+ 'partial-transcript',
+ 'partial-transcript',
+ 'final-transcript',
+ 'agent-roster',
+ 'route-decision',
+ 'dispatch',
+ ]);
+ expect(h.service.getState()).toBe('dispatching');
+ });
+
+ it('passes the roster and recent utterances to the brain', async () => {
+ const spy = vi.spyOn(h.brain, 'route');
+ h.service.submitUtterance('first thing');
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+
+ const context = spy.mock.calls[0][1];
+ expect(context.roster.map((agent) => agent.sessionId)).toEqual(['agent-backend']);
+ expect(context.recentUtterances).toEqual(['first thing']);
+ });
+
+ it('returns to listening on an empty utterance', async () => {
+ h.service.submitUtterance(' ');
+ await vi.waitFor(() => expect(h.types()).toContain('listen-start'));
+
+ expect(h.service.getState()).toBe('listening');
+ expect(h.types()).not.toContain('route-decision');
+ });
+
+ it('refuses an utterance that arrives in a state that cannot take one', async () => {
+ await h.service.stopSession('user');
+ expect(h.service.submitUtterance('too late')).toBe(false);
+ });
+
+ it('abandons a pending reply when the user speaks again', async () => {
+ h.service.submitUtterance('first');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.events.length = 0;
+ expect(h.service.submitUtterance('second')).toBe(true);
+ await vi.waitFor(() => expect(h.types()).toContain('dispatch'));
+ expect(h.events[0].type).toBe('listen-start');
+ });
+});
+
+describe('VoiceSessionService speech', () => {
+ let h: Harness;
+
+ beforeEach(async () => {
+ h = makeHarness();
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ h.events.length = 0;
+ });
+
+ it('speaks a reply sentence by sentence and returns the floor', async () => {
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(h.types()).toEqual([
+ 'agent-reply',
+ 'speak-start',
+ 'speak-sentence',
+ 'speak-sentence',
+ 'speak-end',
+ 'listen-start',
+ ]);
+
+ const start = h.events[1];
+ expect(start.type === 'speak-start' && start.sentenceCount).toBe(2);
+ const sentences = h.events.filter((e) => e.type === 'speak-sentence');
+ expect(sentences.map((e) => (e.type === 'speak-sentence' ? e.text : ''))).toEqual([
+ 'All done.',
+ 'Two files changed.',
+ ]);
+ expect(h.service.getState()).toBe('listening');
+ });
+
+ it('skips the speech run when there is nothing worth speaking', async () => {
+ h.brain.spoken = ' ';
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(h.types()).toEqual(['agent-reply', 'listen-start']);
+ expect(h.service.getState()).toBe('listening');
+ });
+
+ it('ignores a reply that arrives when nothing was dispatched', async () => {
+ await h.service.stopSession('user');
+ const accepted = await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: 'late',
+ });
+ expect(accepted).toBe(false);
+ });
+});
+
+describe('VoiceSessionService barge-in versus stop', () => {
+ let h: Harness;
+
+ beforeEach(async () => {
+ h = makeHarness();
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ h.events.length = 0;
+ });
+
+ it('barge-in cancels speech mid-run and keeps the floor', async () => {
+ // Triggered from the FIRST spoken sentence rather than from a TTS chunk: the
+ // scheduler synthesizes a sentence ahead of the one being heard, so a chunk
+ // arriving says nothing about what the user has actually listened to.
+ h.service.subscribe((event) => {
+ if (event.type === 'speak-sentence' && event.index === 0) h.service.interrupt('voice');
+ });
+
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(h.types()).toEqual([
+ 'agent-reply',
+ 'speak-start',
+ 'speak-sentence',
+ 'barge-in',
+ 'speak-end',
+ 'listen-start',
+ ]);
+ const end = h.events.find((e) => e.type === 'speak-end');
+ expect(end?.type === 'speak-end' && end.reason).toBe('cancelled');
+ expect(h.tts.cancelled).toBe(true);
+ // The floor is retained: still in session, still listening.
+ expect(h.service.getState()).toBe('listening');
+ expect(h.service.getSnapshot().sessionId).toBeTruthy();
+ });
+
+ it('barge-in is a no-op when nothing is speaking', () => {
+ expect(h.service.interrupt('client-button')).toBe(false);
+ expect(h.events).toHaveLength(0);
+ });
+
+ it('the stop word ends the session from any state', async () => {
+ await h.service.hardStop('voice', 'never mind');
+
+ expect(h.types()).toEqual(['stop-word', 'listen-stop']);
+ expect(h.service.getState()).toBe('idle');
+ expect(h.service.getSnapshot().sessionId).toBeNull();
+ expect(h.stt.stopped).toBe(true);
+ });
+
+ it('the stop word cancels speech on the way out', async () => {
+ h.tts.onChunk = () => {
+ void h.service.hardStop('voice');
+ };
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(h.tts.cancelled).toBe(true);
+ expect(h.service.getState()).toBe('idle');
+ });
+});
+
+describe('VoiceSessionService classified failures', () => {
+ it('reports a provider that cannot start', async () => {
+ const h = makeHarness();
+ h.stt.startError = new Error('microphone busy');
+
+ const snapshot = await h.service.startSession({ scope: { kind: 'conductor' } });
+
+ expect(snapshot.state).toBe('error');
+ expect(h.types()).toEqual(['wake', 'session-error']);
+ const error = h.events[1];
+ expect(error.type === 'session-error' && error.code).toBe('provider-unavailable');
+ expect(error.type === 'session-error' && error.providerId).toBe('fake-stt');
+ });
+
+ it('refuses to start when the capability gate blocks a slot, and never substitutes', async () => {
+ const blocked: VoiceSlotReadiness = {
+ slot: 'stt',
+ providerId: 'whisper-local',
+ satisfied: false,
+ reason: 'model-not-installed',
+ detail: 'Speech-to-Text: Whisper Base (English) is not installed.',
+ suggestedAction: 'Download it in Settings > Plugins > A Cappella > Voice Setup.',
+ };
+ const h = makeHarness({
+ checkReadiness: () => ({
+ canStartSession: false,
+ canRunHandsFree: false,
+ slots: [blocked],
+ blocking: [blocked],
+ }),
+ });
+
+ const snapshot = await h.service.startSession({ scope: { kind: 'conductor' } });
+
+ expect(snapshot.state).toBe('error');
+ const error = h.events.find((event) => event.type === 'session-error');
+ expect(error?.type === 'session-error' && error.code).toBe('provider-unavailable');
+ // The missing piece AND the recovery are both named: a disabled voice mode
+ // with no stated reason is indistinguishable from a bug.
+ expect(error?.type === 'session-error' && error.message).toContain('is not installed');
+ expect(error?.type === 'session-error' && error.message).toContain('Download it in Settings');
+ // The gate ran BEFORE the device. Nothing was opened for a session that was
+ // never going to work.
+ expect(h.stt.started).toBe(false);
+ // And the blocked provider is reported as-is, never swapped for a working one.
+ expect(error?.type === 'session-error' && error.providerId).toBe('whisper-local');
+ });
+
+ it('starts normally when the gate is satisfied', async () => {
+ const h = makeHarness({
+ checkReadiness: () => ({
+ canStartSession: true,
+ canRunHandsFree: true,
+ slots: [],
+ blocking: [],
+ }),
+ });
+
+ const snapshot = await h.service.startSession({ scope: { kind: 'conductor' } });
+
+ expect(snapshot.state).toBe('listening');
+ expect(h.stt.started).toBe(true);
+ });
+
+ it('reports a decision that targets an agent which is not running', async () => {
+ const h = makeHarness();
+ await start(h);
+ h.brain.decision = {
+ target: { sessionId: 'agent-ghost' },
+ tabAction: 'current',
+ prompt: 'hi',
+ confidence: 0.5,
+ };
+
+ h.service.submitUtterance('talk to the ghost');
+ await vi.waitFor(() => expect(h.types()).toContain('session-error'));
+
+ const error = h.events.find((e) => e.type === 'session-error');
+ expect(error?.type === 'session-error' && error.code).toBe('no-agent-matched');
+ expect(h.service.getState()).toBe('error');
+ expect(h.types()).not.toContain('dispatch');
+ });
+
+ it('reports a known dispatch failure and swallows nothing else', async () => {
+ const h = makeHarness({
+ executeRoute: async () => {
+ throw new VoiceDispatchError('renderer did not answer in time');
+ },
+ });
+ await start(h);
+
+ h.service.submitUtterance('open a new tab');
+ await vi.waitFor(() => expect(h.types()).toContain('session-error'));
+
+ const error = h.events.find((e) => e.type === 'session-error');
+ expect(error?.type === 'session-error' && error.code).toBe('dispatch-failed');
+ expect(h.service.getState()).toBe('error');
+ });
+
+ it('reports a missing route executor rather than dispatching nowhere', async () => {
+ const stt = new FakeStt();
+ const service = new VoiceSessionService({
+ providers: { stt, tts: new FakeTts(), brain: new FakeBrain() },
+ getRoster: () => makeRoster(),
+ });
+ const events: VoiceEvent[] = [];
+ service.subscribe((e) => events.push(e));
+
+ await service.startSession({ scope: { kind: 'conductor' } });
+ service.submitUtterance('do it');
+ await vi.waitFor(() => expect(events.some((e) => e.type === 'session-error')).toBe(true));
+
+ expect(service.getState()).toBe('error');
+ });
+
+ it('recovers from error only by stopping', async () => {
+ const h = makeHarness();
+ h.stt.startError = new Error('nope');
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(h.service.getState()).toBe('error');
+
+ await h.service.stopSession('error');
+ expect(h.service.getState()).toBe('idle');
+ });
+
+ it('reports an unexpected provider exception to Sentry and closes the floor', async () => {
+ const h = makeHarness();
+ await start(h);
+ h.brain.routeError = new Error('brain exploded');
+
+ h.service.submitUtterance('boom');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('error'));
+
+ const stop = h.events.find((e) => e.type === 'listen-stop');
+ expect(stop?.type === 'listen-stop' && stop.reason).toBe('error');
+ // Unexpected failures are never dressed up as a classified session-error.
+ expect(h.types()).not.toContain('session-error');
+ expect(vi.mocked(captureException)).toHaveBeenCalledWith(
+ h.brain.routeError,
+ expect.objectContaining({ context: 'acappella.runTurn' })
+ );
+ });
+});
+
+describe('VoiceSessionService audio telemetry', () => {
+ it('publishes a meter update on the one ordered stream', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.publishAudioLevel(0.4, true);
+ const event = h.events.at(-1);
+
+ expect(event?.type).toBe('audio-level');
+ expect(event).toMatchObject({ level: 0.4, speech: true, sessionId: expect.any(String) });
+ });
+
+ it('clamps a level rather than putting an impossible number on the wire', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.publishAudioLevel(4, false);
+ h.service.publishAudioLevel(Number.NaN, false);
+
+ const levels = h.events
+ .filter((e) => e.type === 'audio-level')
+ .map((e) => (e as Extract).level);
+ expect(levels).toEqual([1, 0]);
+ });
+
+ it('numbers audio events in the same seq space as the rest', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.publishAudioLevel(0.1, false);
+ h.service.publishMicState({
+ permission: 'granted',
+ capturing: true,
+ deviceId: 'default',
+ deviceLabel: 'Built-in Microphone',
+ issue: null,
+ deviceChanged: false,
+ });
+
+ const seqs = h.events.map((e) => e.seq);
+ expect(seqs).toEqual([...seqs].sort((a, b) => a - b));
+ expect(new Set(seqs).size).toBe(seqs.length);
+ });
+
+ it('publishes the microphone state, including the benign transitions', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.publishMicState({
+ permission: 'denied',
+ capturing: false,
+ deviceId: null,
+ deviceLabel: null,
+ issue: 'permission-denied',
+ deviceChanged: false,
+ });
+
+ expect(h.events.at(-1)).toMatchObject({
+ type: 'mic-state',
+ permission: 'denied',
+ issue: 'permission-denied',
+ });
+ });
+
+ it('drops telemetry that belongs to no session', () => {
+ const h = makeHarness();
+
+ // A frame in flight when the session ended has no envelope to travel in.
+ h.service.publishAudioLevel(0.5, true);
+ expect(h.events).toHaveLength(0);
+ });
+});
+
+describe('VoiceSessionService audio seams', () => {
+ it('exposes the recogniser only while a session exists', async () => {
+ const h = makeHarness();
+
+ // Audio that arrives with no session behind it has nowhere to go, and the
+ // pipeline reads this null to decide to drop it rather than buffer it.
+ expect(h.service.getActiveStt()).toBeNull();
+
+ await start(h);
+ expect(h.service.getActiveStt()).toBe(h.stt);
+
+ await h.service.stopSession('user');
+ expect(h.service.getActiveStt()).toBeNull();
+ });
+
+ it('parks the session on a capture failure the user can fix, and says it is fixable', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.reportAudioCaptureFailure('permission-denied', 'Microphone permission denied');
+
+ expect(h.events.at(-1)).toMatchObject({
+ type: 'session-error',
+ code: 'audio-capture-failed',
+ recoverable: true,
+ });
+ // A listening indicator over a microphone that will never produce a
+ // transcript is the worst outcome this feature has.
+ expect(h.service.getState()).toBe('error');
+ });
+
+ it('reports an environment failure as unrecoverable, so no client offers a fix', async () => {
+ const h = makeHarness();
+ await start(h);
+
+ h.service.reportAudioCaptureFailure('audio-init-failed', 'AudioContext unavailable');
+
+ expect(h.events.at(-1)).toMatchObject({
+ type: 'session-error',
+ code: 'audio-capture-failed',
+ recoverable: false,
+ });
+ });
+
+ it('hands every spoken chunk to the audio sink, after its sentence event', async () => {
+ const seen: Array<{ index: number; eventsSoFar: number }> = [];
+ const h = makeHarness({
+ onSpeechChunk: (chunk) => seen.push({ index: chunk.index, eventsSoFar: h.events.length }),
+ });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ h.events.length = 0;
+
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ // One per sentence, and each after the `speak-sentence` that announced it:
+ // the text should be on screen by the time it is audible. The chunk's own
+ // `index` is the PROVIDER's, and the scheduler synthesises one sentence per
+ // call, so it is 0 for both here - what is being asserted is the ordering
+ // against the events, not a counter the provider owns.
+ expect(seen).toHaveLength(2);
+ const sentenceEvents = h.events
+ .map((event, index) => ({ event, index }))
+ .filter(({ event }) => event.type === 'speak-sentence');
+ expect(sentenceEvents).toHaveLength(2);
+ expect(seen[0].eventsSoFar).toBe(sentenceEvents[0].index + 1);
+ expect(seen[1].eventsSoFar).toBe(sentenceEvents[1].index + 1);
+ });
+
+ it('drops chunks from a run that was cancelled mid-sentence', async () => {
+ const chunks: TtsChunk[] = [];
+ const h = makeHarness({ onSpeechChunk: (chunk) => chunks.push(chunk) });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.service.subscribe((event) => {
+ if (event.type === 'speak-sentence' && event.index === 0) h.service.interrupt('voice');
+ });
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ // The interrupt lands while the first sentence is being announced, so nothing
+ // reaches an output device the user has already talked over - including the
+ // second sentence, which the scheduler had synthesized ahead.
+ expect(chunks).toHaveLength(0);
+ });
+});
+
+describe('VoiceSessionService subscribers', () => {
+ it('keeps delivering when one subscriber throws', async () => {
+ const h = makeHarness();
+ const good = vi.fn();
+ h.service.subscribe(() => {
+ throw new Error('bad client');
+ });
+ h.service.subscribe(good);
+
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(good).toHaveBeenCalled();
+ });
+
+ it('stops delivering after unsubscribe', async () => {
+ const h = makeHarness();
+ const listener = vi.fn();
+ const unsubscribe = h.service.subscribe(listener);
+ unsubscribe();
+
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('drops every subscriber on dispose', async () => {
+ const h = makeHarness();
+ await start(h);
+ await h.service.dispose();
+
+ expect(h.service.getState()).toBe('idle');
+ h.events.length = 0;
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(h.events).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// State machine coverage
+//
+// One test per edge the service can actually reach, each asserting the exact
+// sequence of transitions it took. `DEFENSIVE_EDGES` names the rest, and the
+// last test fails if the table grows an edge that appears in neither list.
+// ---------------------------------------------------------------------------
+
+/** Edges driven end to end by the tests in this block. */
+const DRIVEN_EDGES = [
+ 'idle -> arming',
+ 'arming -> listening',
+ 'arming -> error',
+ 'listening -> transcribing',
+ 'listening -> idle',
+ 'listening -> error',
+ 'transcribing -> routing',
+ 'transcribing -> listening',
+ 'routing -> dispatching',
+ 'routing -> speaking',
+ 'routing -> idle',
+ 'routing -> error',
+ 'dispatching -> speaking',
+ 'dispatching -> listening',
+ 'dispatching -> idle',
+ 'dispatching -> error',
+ 'speaking -> interrupted',
+ 'speaking -> listening',
+ 'speaking -> idle',
+ 'speaking -> error',
+ 'interrupted -> listening',
+ 'error -> idle',
+];
+
+/**
+ * Edges the table allows that nothing in Phase 01 can reach. Each is a guard
+ * against a shape a later phase adds, not dead weight: leaving them out of the
+ * table would turn that phase's first real failure into a thrown
+ * `InvalidVoiceStateTransitionError` instead of a clean teardown.
+ */
+const DEFENSIVE_EDGES = [
+ // `arming` is only held across `stt.start()`, which no client can interrupt
+ // today. A real microphone permission prompt (Phase 05) is cancellable.
+ 'arming -> idle',
+ // `transcribing` and `interrupted` are both crossed synchronously, so nothing
+ // can stop or fail a session while it is in either one.
+ 'transcribing -> idle',
+ 'transcribing -> error',
+ 'interrupted -> idle',
+ 'interrupted -> error',
+];
+
+describe('VoiceSessionService state machine', () => {
+ let h: Harness;
+
+ beforeEach(() => {
+ h = makeHarness();
+ takeEdges();
+ });
+
+ it('idle -> arming -> listening on wake', async () => {
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(takeEdges()).toEqual(['idle -> arming', 'arming -> listening']);
+ });
+
+ it('arming -> error when the speech provider will not open', async () => {
+ h.stt.startError = new Error('microphone busy');
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ expect(takeEdges()).toEqual(['idle -> arming', 'arming -> error']);
+ });
+
+ it('error -> idle is the only way out of error', async () => {
+ h.stt.startError = new Error('microphone busy');
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ takeEdges();
+
+ await h.service.stopSession('error');
+ expect(takeEdges()).toEqual(['error -> idle']);
+ expect(h.service.getState()).toBe('idle');
+ });
+
+ it('listening -> idle when the session is stopped', async () => {
+ await start(h);
+ await h.service.stopSession('user');
+ expect(takeEdges()).toEqual(['listening -> idle']);
+ });
+
+ it('listening -> error when the speech provider drops out mid-session', async () => {
+ await start(h);
+ h.stt.callbacks?.onError(new Error('device disappeared'));
+ expect(takeEdges()).toEqual(['listening -> error']);
+ });
+
+ it('listening -> transcribing -> routing -> dispatching for a full utterance', async () => {
+ await start(h);
+ h.service.submitUtterance('open the auth tab');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ expect(takeEdges()).toEqual([
+ 'listening -> transcribing',
+ 'transcribing -> routing',
+ 'routing -> dispatching',
+ ]);
+ });
+
+ it('transcribing -> listening when the utterance was empty', async () => {
+ await start(h);
+ h.service.submitUtterance(' ');
+ await vi.waitFor(() => expect(transitionLog.edges).toContain('transcribing -> listening'));
+
+ expect(takeEdges()).toEqual(['listening -> transcribing', 'transcribing -> listening']);
+ });
+
+ it('routing -> idle when the stop word lands while the brain is still thinking', async () => {
+ await start(h);
+ const gate = deferred();
+ h.brain.routeGate = gate.promise;
+
+ h.service.submitUtterance('think about it');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('routing'));
+
+ await h.service.hardStop('voice', 'never mind');
+ expect(h.service.getState()).toBe('idle');
+
+ // The superseded turn resumes and drops itself rather than transitioning.
+ gate.resolve();
+ await gate.promise;
+ expect(takeEdges()).toEqual([
+ 'listening -> transcribing',
+ 'transcribing -> routing',
+ 'routing -> idle',
+ ]);
+ });
+
+ it('routing -> error when the decision names an agent that is gone', async () => {
+ await start(h);
+ h.brain.decision = {
+ target: { sessionId: 'agent-ghost' },
+ tabAction: 'current',
+ prompt: 'hi',
+ confidence: 0.5,
+ };
+
+ h.service.submitUtterance('talk to the ghost');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('error'));
+
+ expect(takeEdges()).toEqual([
+ 'listening -> transcribing',
+ 'transcribing -> routing',
+ 'routing -> error',
+ ]);
+ });
+
+ it('dispatching -> error when the dispatch itself fails', async () => {
+ const failing = makeHarness({
+ executeRoute: async () => {
+ throw new VoiceDispatchError('renderer did not answer in time');
+ },
+ });
+ await start(failing);
+
+ failing.service.submitUtterance('open a new tab');
+ await vi.waitFor(() => expect(failing.service.getState()).toBe('error'));
+
+ expect(takeEdges()).toEqual([
+ 'listening -> transcribing',
+ 'transcribing -> routing',
+ 'routing -> dispatching',
+ 'dispatching -> error',
+ ]);
+ });
+
+ it('dispatching -> speaking -> listening for a spoken reply', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(takeEdges()).toEqual(['dispatching -> speaking', 'speaking -> listening']);
+ });
+
+ it('dispatching -> listening when the reply is not worth speaking', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ h.brain.spoken = ' ';
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(takeEdges()).toEqual(['dispatching -> listening']);
+ });
+
+ it('dispatching -> idle when the session is stopped before the reply lands', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ await h.service.stopSession('user');
+ expect(takeEdges()).toEqual(['dispatching -> idle']);
+ });
+
+ it('speaking -> interrupted -> listening on barge-in, keeping the floor', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ h.tts.onChunk = () => {
+ h.service.interrupt('voice');
+ };
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(takeEdges()).toEqual([
+ 'dispatching -> speaking',
+ 'speaking -> interrupted',
+ 'interrupted -> listening',
+ ]);
+ expect(h.service.getSnapshot().sessionId).toBeTruthy();
+ });
+
+ it('speaking -> idle when the stop word lands mid-sentence', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ h.tts.onChunk = () => {
+ void h.service.hardStop('voice');
+ };
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+ await vi.waitFor(() => expect(h.service.getState()).toBe('idle'));
+
+ expect(takeEdges()).toEqual(['dispatching -> speaking', 'speaking -> idle']);
+ });
+
+ it('speaking -> error when the voice throws mid-run, releasing the floor', async () => {
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ takeEdges();
+
+ h.tts.speakError = new Error('voice stream closed');
+ // The rejection must not escape: a caller that only awaited this would
+ // otherwise leave the session in `speaking` with the floor held.
+ await expect(
+ h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ })
+ ).resolves.toBe(true);
+
+ expect(takeEdges()).toEqual(['dispatching -> speaking', 'speaking -> error']);
+ expect(h.service.getState()).toBe('error');
+ expect(h.events.filter((e) => e.type === 'listen-stop')).toEqual([
+ expect.objectContaining({ reason: 'error' }),
+ ]);
+ });
+
+ it('never takes an edge the table does not name', async () => {
+ await start(h);
+ h.service.submitUtterance('open the auth tab');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: 'done',
+ });
+ await h.service.stopSession('user');
+
+ // The wrapper delegates, so an illegal edge would already have thrown. This
+ // asserts the recorder is watching the same table the service asserts on.
+ for (const edge of takeEdges()) {
+ const [from, to] = edge.split(' -> ') as [VoiceSessionState, VoiceSessionState];
+ expect(VOICE_STATE_TRANSITIONS[from]).toContain(to);
+ }
+ });
+
+ it('routing -> speaking when the router asks instead of guessing', async () => {
+ await start(h);
+ h.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'run it',
+ confidence: 0.3,
+ clarify: 'the backend agent or the API agent?',
+ };
+
+ h.service.submitUtterance('run it');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ // Nothing was dispatched: the question IS the turn, and the answer arrives
+ // as the next utterance.
+ expect(h.executor).not.toHaveBeenCalled();
+ expect(takeEdges()).toEqual([
+ 'listening -> transcribing',
+ 'transcribing -> routing',
+ 'routing -> speaking',
+ 'speaking -> listening',
+ ]);
+ });
+
+ it('has a driving test or a documented reason for every edge in the table', () => {
+ const declared = Object.entries(VOICE_STATE_TRANSITIONS).flatMap(([from, targets]) =>
+ targets.map((to) => `${from} -> ${to}`)
+ );
+
+ expect(DRIVEN_EDGES.filter((edge) => DEFENSIVE_EDGES.includes(edge))).toEqual([]);
+ expect([...DRIVEN_EDGES, ...DEFENSIVE_EDGES].sort()).toEqual([...declared].sort());
+ });
+});
+
+describe('illegal transitions throw', () => {
+ it('surfaces the offending edge', () => {
+ expect(() => {
+ throw new InvalidVoiceStateTransitionError('listening', 'speaking');
+ }).toThrow(/listening -> speaking/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Disambiguation and correction
+//
+// The two paths that exist so a low-confidence guess never becomes a spoken
+// instruction in the wrong repository: asking before dispatching, and moving a
+// dispatch the user says went to the wrong place.
+// ---------------------------------------------------------------------------
+
+describe('spoken disambiguation', () => {
+ let h: Harness;
+
+ beforeEach(() => {
+ h = makeHarness();
+ });
+
+ afterEach(async () => {
+ await h.service.stopSession('user');
+ });
+
+ function askAbout(question: string): void {
+ h.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'run it',
+ confidence: 0.3,
+ clarify: question,
+ };
+ }
+
+ it('speaks the question and hands the floor straight back', async () => {
+ await start(h);
+ askAbout('the backend agent or the API agent?');
+
+ h.service.submitUtterance('run it');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ expect(h.types()).toContain('route-decision');
+ expect(h.events.filter((e) => e.type === 'speak-sentence')).toEqual([
+ expect.objectContaining({ text: 'the backend agent or the API agent?' }),
+ ]);
+ expect(h.types()).not.toContain('dispatch');
+ });
+
+ it('routes the ORIGINAL request on the answer, not the fragment', async () => {
+ await start(h);
+ askAbout('the backend agent or the API agent?');
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ const contexts: VoiceRouteContext[] = [];
+ h.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'deploy the gateway',
+ confidence: 0.9,
+ };
+ const originalRoute = h.brain.route.bind(h.brain);
+ h.brain.route = async (input, context) => {
+ contexts.push(context);
+ return originalRoute(input, context);
+ };
+
+ h.service.submitUtterance('the backend one');
+ await vi.waitFor(() => expect(h.executor).toHaveBeenCalled());
+
+ // "the backend one" routed on its own becomes a prompt, and the request it
+ // was answering is lost.
+ expect(contexts[0].clarification).toEqual({
+ question: 'the backend agent or the API agent?',
+ utterance: 'deploy the gateway',
+ });
+ });
+
+ it('forgets an abandoned question rather than reinterpreting a later sentence', async () => {
+ await start(h);
+ askAbout('the backend agent or the API agent?');
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ const contexts: VoiceRouteContext[] = [];
+ h.brain.decision = {
+ target: 'conductor',
+ tabAction: 'current',
+ prompt: 'x',
+ confidence: 0.9,
+ };
+ h.brain.route = async (_input, context) => {
+ contexts.push(context);
+ return h.brain.decision;
+ };
+
+ h.service.submitUtterance('the backend one');
+ await vi.waitFor(() => expect(contexts).toHaveLength(1));
+ h.service.submitUtterance('something else entirely');
+ await vi.waitFor(() => expect(contexts).toHaveLength(2));
+
+ expect(contexts[1].clarification).toBeUndefined();
+ });
+});
+
+describe('wrong-tab correction', () => {
+ /** Two agents, so "the other one" has an unambiguous answer. */
+ function twoAgentRoster(): RosterAgent[] {
+ return [
+ ...makeRoster(),
+ {
+ sessionId: 'agent-api',
+ name: 'API',
+ agentType: 'codex',
+ cwd: '/repo/gateway',
+ tabs: [{ id: 'tab-gw', name: 'Gateway', lastActiveAt: 2 }],
+ },
+ ];
+ }
+
+ function makeCorrectionHarness() {
+ const stt = new FakeStt();
+ const tts = new FakeTts();
+ const brain = new FakeBrain();
+ brain.decision = {
+ target: { sessionId: 'agent-backend' },
+ tabAction: 'current',
+ prompt: 'deploy the gateway',
+ confidence: 0.9,
+ };
+
+ const executed: RouteDecision[] = [];
+ const executor = vi.fn(async (decision: RouteDecision) => {
+ executed.push(decision);
+ const target =
+ typeof decision.target === 'string' ? 'agent-backend' : decision.target.sessionId;
+ return {
+ agentSessionId: target,
+ agentName: target === 'agent-api' ? 'API' : 'Backend',
+ tabId: target === 'agent-api' ? 'tab-gw' : 'tab-1',
+ action: 'focused' as const,
+ promptSent: true,
+ };
+ });
+
+ const service = new VoiceSessionService({
+ providers: { stt, tts, brain },
+ getRoster: () => twoAgentRoster(),
+ executeRoute: executor as unknown as VoiceRouteExecutor,
+ });
+ const events: VoiceEvent[] = [];
+ service.subscribe((event) => events.push(event));
+
+ return { service, stt, tts, brain, events, executor, executed };
+ }
+
+ it('moves the last prompt on a spoken "no, the other one"', async () => {
+ const h = makeCorrectionHarness();
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.service.submitUtterance('no, the other one');
+ await vi.waitFor(() => expect(h.executed).toHaveLength(2));
+
+ // The prompt that was actually sent, not the correction phrase.
+ expect(h.executed[1]).toMatchObject({
+ target: { sessionId: 'agent-api' },
+ prompt: 'deploy the gateway',
+ });
+ const correction = h.events.find((event) => event.type === 'route-correction');
+ expect(correction).toMatchObject({
+ fromAgentSessionId: 'agent-backend',
+ agentSessionId: 'agent-api',
+ source: 'voice',
+ });
+ await h.service.stopSession('user');
+ });
+
+ it('never sends a correction phrase to an agent as a prompt', async () => {
+ const h = makeCorrectionHarness();
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.service.submitUtterance('wrong agent');
+ await vi.waitFor(() => expect(h.executed).toHaveLength(2));
+
+ expect(h.executed.map((decision) => decision.prompt)).toEqual([
+ 'deploy the gateway',
+ 'deploy the gateway',
+ ]);
+ await h.service.stopSession('user');
+ });
+
+ it('does nothing when there is no dispatch to correct', async () => {
+ const h = makeCorrectionHarness();
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+
+ await expect(h.service.correctLastDispatch('agent-api')).resolves.toBe(false);
+ await h.service.stopSession('user');
+ });
+
+ it('takes a correction from a HUD control as well as from the voice', async () => {
+ const h = makeCorrectionHarness();
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ await expect(h.service.correctLastDispatch('agent-api')).resolves.toBe(true);
+
+ expect(h.events.find((event) => event.type === 'route-correction')).toMatchObject({
+ source: 'client-button',
+ agentName: 'API',
+ });
+ await h.service.stopSession('user');
+ });
+
+ it('surfaces the last decision and dispatch in the snapshot', async () => {
+ const h = makeCorrectionHarness();
+ await h.service.startSession({ scope: { kind: 'conductor' } });
+ h.service.submitUtterance('deploy the gateway');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ const snapshot = h.service.getSnapshot();
+
+ expect(snapshot.lastDecision).toMatchObject({ confidence: 0.9 });
+ expect(snapshot.lastDispatch).toMatchObject({ agentName: 'Backend', tabId: 'tab-1' });
+ await h.service.stopSession('user');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The Phase 08 speech layer, wired
+// ---------------------------------------------------------------------------
+
+/** A fake tap: records what it was asked to follow, and lets a test push chunks. */
+function makeReplyStream(): AgentReplyStream & { watched: string[]; unwatched: string[] } {
+ const watched: string[] = [];
+ const unwatched: string[] = [];
+ return {
+ watched,
+ unwatched,
+ watch: ({ agentSessionId, tabId }) => watched.push(`${agentSessionId}/${tabId}`),
+ unwatch: ({ agentSessionId, tabId }) => unwatched.push(`${agentSessionId}/${tabId}`),
+ };
+}
+
+function chunk(overrides: Partial = {}): AgentOutputChunk {
+ return {
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ kind: 'text',
+ text: AGENT_REPLY,
+ ts: 1,
+ ...overrides,
+ };
+}
+
+describe('VoiceSessionService streamed agent output', () => {
+ /** Dispatch a turn and leave the session waiting on the tap. */
+ async function dispatched(overrides: Parameters[0] = {}) {
+ const stream = overrides.agentReplyStream ?? makeReplyStream();
+ const h = makeHarness({ ...overrides, agentReplyStream: stream });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ h.events.length = 0;
+ return { h, stream: stream as ReturnType };
+ }
+
+ it('follows the dispatched tab and speaks a chunk before the turn is over', async () => {
+ const { h, stream } = await dispatched();
+ expect(stream.watched).toEqual(['agent-backend/tab-1']);
+
+ h.service.pushAgentOutput(chunk());
+ await vi.waitFor(() => expect(h.types()).toContain('speak-sentence'));
+
+ // Still speaking: the agent has not said it is finished, so the run stays
+ // open for the rest of the reply rather than closing after the first thought.
+ expect(h.service.getState()).toBe('speaking');
+ expect(h.types()).not.toContain('speak-end');
+ });
+
+ it('announces a streamed run as streaming, so the count is a lower bound', async () => {
+ const { h } = await dispatched();
+ h.service.pushAgentOutput(chunk());
+ await vi.waitFor(() => expect(h.types()).toContain('speak-start'));
+
+ const started = h.events.find((event) => event.type === 'speak-start');
+ expect(started).toMatchObject({ streaming: true, sentenceCount: 0 });
+ });
+
+ it('closes the run and hands the floor back on the final chunk', async () => {
+ const { h, stream } = await dispatched();
+
+ h.service.pushAgentOutput(chunk({ kind: 'final' }));
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ // The speech events in order. `agent-reply` is deliberately not pinned in
+ // among them: the record of the chunk is written when its rewrite finishes,
+ // while its last sentences are still being spoken, and holding it back until
+ // the audio caught up would put the transcript behind the voice.
+ expect(h.types().filter((type) => type !== 'agent-reply')).toEqual([
+ 'speak-start',
+ 'speak-sentence',
+ 'speak-sentence',
+ 'speak-end',
+ 'listen-start',
+ ]);
+ expect(h.types()).toContain('agent-reply');
+ expect(stream.unwatched).toEqual(['agent-backend/tab-1']);
+ });
+
+ it('hands the floor back when the whole turn produced nothing speakable', async () => {
+ const { h } = await dispatched();
+ h.brain.spoken = ' ';
+
+ h.service.pushAgentOutput(chunk({ kind: 'final' }));
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ // No run was ever opened: a chunk the translator had nothing to say about
+ // must not strand the session in `speaking` with a silent floor.
+ expect(h.types()).toEqual(['listen-start']);
+ });
+
+ it('speaks a status chunk straight through, without a translation hop', async () => {
+ const { h } = await dispatched();
+
+ h.service.pushAgentOutput(chunk({ kind: 'status', text: 'It hit an error: exit code 1.' }));
+ await vi.waitFor(() => expect(h.types()).toContain('speak-sentence'));
+
+ expect(h.events.filter((event) => event.type === 'speak-sentence')).toEqual([
+ expect.objectContaining({ text: 'It hit an error: exit code 1.' }),
+ ]);
+ });
+
+ it('ignores output from a tab this turn is not about', async () => {
+ const { h } = await dispatched();
+
+ h.service.pushAgentOutput(chunk({ tabId: 'tab-other' }));
+ await Promise.resolve();
+
+ expect(h.events).toHaveLength(0);
+ expect(h.service.getState()).toBe('dispatching');
+ });
+
+ it('stops following the tab when the session ends', async () => {
+ const { h, stream } = await dispatched();
+ await h.service.stopSession('user');
+ expect(stream.unwatched).toEqual(['agent-backend/tab-1']);
+ });
+});
+
+describe('VoiceSessionService follow-ups', () => {
+ /** Speak one reply, so there is a retained turn to drill into. */
+ async function afterAReply(overrides: Parameters[0] = {}) {
+ const h = makeHarness(overrides);
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: `${AGENT_REPLY}\nThe stale check lived in src/main/auth/session.ts.`,
+ });
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+ h.events.length = 0;
+ h.executor.mockClear();
+ return h;
+ }
+
+ it('serves "tell me more" from the retained output, with no agent turn', async () => {
+ const h = await afterAReply();
+
+ h.service.submitUtterance('tell me more');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ expect(h.executor).not.toHaveBeenCalled();
+ expect(h.types()).not.toContain('route-decision');
+ expect(h.events.filter((event) => event.type === 'speak-sentence').length).toBeGreaterThan(0);
+ });
+
+ it('repeats what was actually said, not what was queued', async () => {
+ const h = await afterAReply();
+
+ h.service.submitUtterance('say that again');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ const spoken = h.events
+ .filter((event) => event.type === 'speak-sentence')
+ .map((event) => (event.type === 'speak-sentence' ? event.text : ''));
+ expect(spoken.join(' ')).toContain('All done.');
+ });
+
+ it('answers "show me" on screen and says nothing at all', async () => {
+ const focused: unknown[] = [];
+ const h = await afterAReply({ focusTarget: (target) => focused.push(target) });
+
+ h.service.submitUtterance('show me that file');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('listening'));
+
+ expect(focused).toEqual([
+ expect.objectContaining({ agentSessionId: 'agent-backend', tabId: 'tab-1' }),
+ ]);
+ expect(h.types()).not.toContain('speak-start');
+ expect(h.executor).not.toHaveBeenCalled();
+ });
+
+ it('still routes a fresh request that only looks like a follow-up', async () => {
+ const h = await afterAReply();
+
+ h.service.submitUtterance('open a new tab for the migration');
+ await vi.waitFor(() => expect(h.executor).toHaveBeenCalled());
+
+ expect(h.types()).toContain('route-decision');
+ });
+});
+
+describe('VoiceSessionService barge-in guard window', () => {
+ it('refuses a voice barge-in inside the guard window', async () => {
+ const h = makeHarness({ bargeInGuardMs: 60_000 });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.service.subscribe((event) => {
+ if (event.type === 'speak-sentence' && event.index === 0) h.service.interrupt('voice');
+ });
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ // The assistant's own leaked syllable must not interrupt the assistant.
+ expect(h.types()).not.toContain('barge-in');
+ expect(h.events.filter((event) => event.type === 'speak-sentence')).toHaveLength(2);
+ });
+
+ it('still takes a button press inside the guard window', async () => {
+ const h = makeHarness({ bargeInGuardMs: 60_000 });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+
+ h.service.subscribe((event) => {
+ if (event.type === 'speak-sentence' && event.index === 0) {
+ h.service.interrupt('client-button');
+ }
+ });
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+
+ expect(h.types()).toContain('barge-in');
+ expect(h.service.getState()).toBe('listening');
+ });
+});
+
+describe('VoiceSessionService background completions', () => {
+ const completion = {
+ agentSessionId: 'agent-api',
+ agentName: 'API',
+ summary: 'the migration',
+ };
+
+ it('speaks a completion at a pause, naming the source', async () => {
+ const h = makeHarness({ getBackgroundAnnouncementSetting: () => 'on' });
+ await start(h);
+
+ expect(h.service.noteAgentCompletion(completion)).toBe(true);
+ await vi.waitFor(() => expect(h.types()).toContain('speak-sentence'));
+
+ const spoken = h.events
+ .filter((event) => event.type === 'speak-sentence')
+ .map((event) => (event.type === 'speak-sentence' ? event.text : ''));
+ expect(spoken.join(' ')).toContain('the API agent finished the migration');
+ });
+
+ it('waits for the pause rather than talking over the turn in progress', async () => {
+ const h = makeHarness({ getBackgroundAnnouncementSetting: () => 'on' });
+ await start(h);
+ h.service.submitUtterance('what changed');
+ await vi.waitFor(() => expect(h.service.getState()).toBe('dispatching'));
+ h.events.length = 0;
+
+ expect(h.service.noteAgentCompletion(completion)).toBe(true);
+ expect(h.types()).toEqual([]);
+
+ await h.service.submitAgentReply({
+ agentSessionId: 'agent-backend',
+ tabId: 'tab-1',
+ text: AGENT_REPLY,
+ });
+ await vi.waitFor(() => {
+ const spoken = h.events
+ .filter((event) => event.type === 'speak-sentence')
+ .map((event) => (event.type === 'speak-sentence' ? event.text : ''));
+ expect(spoken.join(' ')).toContain('the API agent finished');
+ });
+ });
+
+ it('says nothing when the setting is off', async () => {
+ const h = makeHarness({ getBackgroundAnnouncementSetting: () => 'off' });
+ await start(h);
+
+ expect(h.service.noteAgentCompletion(completion)).toBe(false);
+ expect(h.types()).toEqual([]);
+ });
+});
diff --git a/src/__tests__/main/acappella/wake/stop-word.test.ts b/src/__tests__/main/acappella/wake/stop-word.test.ts
new file mode 100644
index 0000000000..792784ae05
--- /dev/null
+++ b/src/__tests__/main/acappella/wake/stop-word.test.ts
@@ -0,0 +1,297 @@
+/**
+ * @file stop-word.test.ts
+ *
+ * The stop word, and the one property this whole module exists to protect:
+ * **the stop word and barge-in are different things and produce different
+ * terminal states.** Stopping while the assistant is speaking must end the
+ * session; talking over it must not. A test that only checked "speech stopped"
+ * would pass for both and catch neither.
+ *
+ * The session is a fake with the same state machine shape the real one has, so
+ * the assertions are about the state the session ends in rather than about which
+ * methods were called.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+vi.mock('../../../../main/acappella/models/model-store', () => ({
+ modelFilePath: (id: string, file: string) => `/models/${id}/${file}`,
+}));
+
+import type { AudioHostCommand } from '../../../../shared/acappella/audio-host';
+import type { InterruptSource } from '../../../../shared/acappella/protocol';
+import type { VoiceSessionState } from '../../../../shared/acappella/session-state';
+import {
+ DEFAULT_STOP_PHRASE,
+ FALLBACK_STOP_PHRASE,
+ FALLBACK_STOP_PHRASE_ID,
+ PRIMARY_STOP_PHRASE_ID,
+ StopWordController,
+ armedPhrases,
+ createStopWordController,
+ isStopPhraseId,
+ stopWordPhrases,
+ type StopWordEventInfo,
+ type StopWordSession,
+} from '../../../../main/acappella/wake/stop-word';
+import {
+ globalWakePhrase,
+ type WakeDetection,
+} from '../../../../main/acappella/wake/wake-detector';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/**
+ * A session with both behaviours on it, so a test can compare them.
+ *
+ * `interrupt` mirrors the real service: it cancels speech and returns the floor
+ * to `listening`. `hardStop` ends the session. The controller is only given the
+ * `hardStop` half, which is the point.
+ */
+class FakeSession implements StopWordSession {
+ state: VoiceSessionState = 'idle';
+ speechCancelled = 0;
+ readonly hardStops: Array<{ source?: InterruptSource; phrase?: string }> = [];
+
+ getState(): VoiceSessionState {
+ return this.state;
+ }
+
+ async hardStop(source?: InterruptSource, phrase?: string): Promise {
+ this.hardStops.push({ source, phrase });
+ this.speechCancelled += 1;
+ this.state = 'idle';
+ }
+
+ /** Barge-in, for the comparison test. Never reachable from the controller. */
+ interrupt(): boolean {
+ if (this.state !== 'speaking') return false;
+ this.speechCancelled += 1;
+ this.state = 'listening';
+ return true;
+ }
+}
+
+function detection(phraseId: string, phrase: string): WakeDetection {
+ return { phraseId, phrase, scope: { kind: 'conductor' }, score: 0.9, at: 1234, preRoll: [] };
+}
+
+describe('stop phrases', () => {
+ it('always arms a second, non-editable phrase alongside the configured one', () => {
+ const phrases = stopWordPhrases({ phrase: 'that will do' });
+ expect(phrases.map((p) => p.phrase)).toEqual(['that will do', FALLBACK_STOP_PHRASE]);
+ });
+
+ it('falls back to the default phrase when the setting is blank', () => {
+ expect(stopWordPhrases({ phrase: ' ' })[0].phrase).toBe(DEFAULT_STOP_PHRASE);
+ });
+
+ it('keeps "nevermind" armed even when the configured phrase is switched off', () => {
+ const phrases = stopWordPhrases({ enabled: false });
+ expect(phrases[0].enabled).toBe(false);
+ expect(phrases[1].enabled).not.toBe(false);
+ });
+
+ it('tags stop phrase ids so a detection can be routed without a lookup', () => {
+ expect(isStopPhraseId(PRIMARY_STOP_PHRASE_ID)).toBe(true);
+ expect(isStopPhraseId(FALLBACK_STOP_PHRASE_ID)).toBe(true);
+ expect(isStopPhraseId('global')).toBe(false);
+ expect(isStopPhraseId('agent:seven')).toBe(false);
+ });
+});
+
+describe('armedPhrases', () => {
+ const wake = [globalWakePhrase('hey maestro')];
+ const stop = stopWordPhrases();
+
+ it('listens for the wake word only while the session is cold', () => {
+ for (const state of ['idle', 'error'] as VoiceSessionState[]) {
+ expect(armedPhrases(state, { wake, stop })).toEqual(wake);
+ }
+ });
+
+ it('listens for the stop word in every active state, including speaking', () => {
+ const active: VoiceSessionState[] = [
+ 'arming',
+ 'listening',
+ 'transcribing',
+ 'routing',
+ 'dispatching',
+ 'speaking',
+ 'interrupted',
+ ];
+ for (const state of active) {
+ expect(armedPhrases(state, { wake, stop })).toEqual(stop);
+ }
+ });
+
+ it('never arms both at once, so a wake phrase cannot stack a second session', () => {
+ const armed = armedPhrases('speaking', { wake, stop });
+ expect(armed.some((phrase) => phrase.id === 'global')).toBe(false);
+ });
+});
+
+describe('StopWordController', () => {
+ let session: FakeSession;
+ let commands: AudioHostCommand[];
+ let stops: StopWordEventInfo[];
+ let backToWakeOnly: number;
+ let controller: StopWordController;
+
+ beforeEach(() => {
+ session = new FakeSession();
+ commands = [];
+ stops = [];
+ backToWakeOnly = 0;
+ controller = createStopWordController({
+ session,
+ sendCommand: (command) => commands.push(command),
+ onStopWord: (info) => stops.push(info),
+ onWakeWordOnly: () => {
+ backToWakeOnly += 1;
+ },
+ });
+ });
+
+ it('ignores a wake phrase', () => {
+ expect(controller.handleDetection(detection('global', 'hey maestro'))).toBe(false);
+ expect(session.hardStops).toHaveLength(0);
+ });
+
+ it('ends the session when heard while speaking', async () => {
+ session.state = 'speaking';
+ expect(controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'))).toBe(
+ true
+ );
+ await controller.whenSettled();
+
+ expect(session.state).toBe('idle');
+ expect(session.hardStops).toEqual([{ source: 'voice', phrase: 'maestro stop' }]);
+ });
+
+ it('ends the session when heard while listening', async () => {
+ session.state = 'listening';
+ controller.handleDetection(detection(FALLBACK_STOP_PHRASE_ID, FALLBACK_STOP_PHRASE));
+ await controller.whenSettled();
+
+ expect(session.state).toBe('idle');
+ expect(stops[0].from).toBe('listening');
+ });
+
+ it('flushes queued playback before stopping, then closes the microphone', async () => {
+ session.state = 'speaking';
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await controller.whenSettled();
+
+ expect(commands.map((command) => command.kind)).toEqual(['flush', 'stop-capture']);
+ });
+
+ it('goes back to wake-word-only listening', async () => {
+ session.state = 'speaking';
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await controller.whenSettled();
+
+ expect(backToWakeOnly).toBe(1);
+ });
+
+ it('does nothing when there is no session to stop', async () => {
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await controller.whenSettled();
+
+ expect(session.hardStops).toHaveLength(0);
+ expect(commands).toHaveLength(0);
+ expect(stops).toHaveLength(0);
+ });
+
+ it('serialises two phrases heard back to back into one teardown', async () => {
+ session.state = 'speaking';
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ controller.handleDetection(detection(FALLBACK_STOP_PHRASE_ID, FALLBACK_STOP_PHRASE));
+ await controller.whenSettled();
+
+ expect(session.hardStops).toHaveLength(1);
+ });
+
+ it('still closes the microphone when the session teardown throws', async () => {
+ session.state = 'speaking';
+ session.hardStop = async () => {
+ throw new Error('teardown exploded');
+ };
+ const errors: Error[] = [];
+ const failing = createStopWordController({
+ session,
+ sendCommand: (command) => commands.push(command),
+ onError: (error) => errors.push(error),
+ });
+
+ failing.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await failing.whenSettled();
+
+ expect(errors).toHaveLength(1);
+ expect(commands.map((command) => command.kind)).toEqual(['flush', 'stop-capture']);
+ });
+
+ it('reads the stop phrase per call, so a settings change takes effect live', () => {
+ let phrase = 'first phrase';
+ const live = createStopWordController({ session, getConfig: () => ({ phrase }) });
+ expect(live.phrases()[0].phrase).toBe('first phrase');
+ phrase = 'second phrase';
+ expect(live.phrases()[0].phrase).toBe('second phrase');
+ });
+
+ // -----------------------------------------------------------------------
+ // The distinction
+ // -----------------------------------------------------------------------
+
+ describe('stop word versus barge-in', () => {
+ it('leaves the session in different terminal states from the same starting point', async () => {
+ const stopped = new FakeSession();
+ stopped.state = 'speaking';
+ const stopController = createStopWordController({ session: stopped });
+ stopController.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await stopController.whenSettled();
+
+ const bargedIn = new FakeSession();
+ bargedIn.state = 'speaking';
+ bargedIn.interrupt();
+
+ // Both cancelled speech. Only one of them hung up.
+ expect(stopped.speechCancelled).toBe(1);
+ expect(bargedIn.speechCancelled).toBe(1);
+ expect(stopped.state).toBe('idle');
+ expect(bargedIn.state).toBe('listening');
+ expect(stopped.state).not.toBe(bargedIn.state);
+ });
+
+ it('cannot reach barge-in: the controller is handed no way to call it', async () => {
+ const spy = vi.spyOn(session, 'interrupt');
+ session.state = 'speaking';
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await controller.whenSettled();
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('reports the stop through its own seam, not a shared one', async () => {
+ session.state = 'speaking';
+ controller.handleDetection(detection(PRIMARY_STOP_PHRASE_ID, 'maestro stop'));
+ await controller.whenSettled();
+
+ expect(stops).toEqual([
+ {
+ phrase: 'maestro stop',
+ phraseId: PRIMARY_STOP_PHRASE_ID,
+ from: 'speaking',
+ score: 0.9,
+ at: 1234,
+ },
+ ]);
+ });
+ });
+});
diff --git a/src/__tests__/main/acappella/wake/wake-detector.test.ts b/src/__tests__/main/acappella/wake/wake-detector.test.ts
new file mode 100644
index 0000000000..2b426bfb4b
--- /dev/null
+++ b/src/__tests__/main/acappella/wake/wake-detector.test.ts
@@ -0,0 +1,410 @@
+/**
+ * @file wake-detector.test.ts
+ *
+ * The always-local wake word: phrase matching against a per-phrase sensitivity,
+ * the debounce that stops one spoken phrase becoming three sessions, per-agent
+ * scope resolution, pre-roll inclusion, and the invariant this whole subsystem
+ * exists to hold - **while only the wake detector is running, no audio frame
+ * reaches a hosted provider or leaves the process.**
+ *
+ * The scorer is injected, so the orchestration is tested with synthetic frames
+ * and deterministic scores rather than against a trained model. That is the
+ * right seam: everything above it is where a wake word gets a scope wrong or
+ * clips the user's first word, and none of that depends on ONNX.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../../main/utils/sentry', () => ({ captureException: vi.fn() }));
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+// The detector reaches the model store only through the ONNX scorer, which no
+// test here builds. Mocked so importing the module does not pull in `electron`.
+vi.mock('../../../../main/acappella/models/model-store', () => ({
+ modelFilePath: (id: string, file: string) => `/models/${id}/${file}`,
+}));
+
+import { ACAPPELLA_AUDIO_FRAME_SAMPLES } from '../../../../shared/acappella/audio-host';
+import type { SttProvider } from '../../../../shared/acappella/providers';
+import {
+ DEFAULT_WAKE_DEBOUNCE_MS,
+ GLOBAL_WAKE_PHRASE_ID,
+ WAKE_HOP_SAMPLES,
+ WakeDetector,
+ agentWakePhrase,
+ assertWakeScorerLocal,
+ createWakeDetector,
+ globalWakePhrase,
+ wakeThresholdFor,
+ type WakeDetection,
+ type WakePhrase,
+ type WakePhraseScorer,
+} from '../../../../main/acappella/wake/wake-detector';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/** A scorer whose answer is set by the test. Local by construction. */
+class ScriptedScorer implements WakePhraseScorer {
+ readonly tier = 'local' as const;
+ scores: Record = {};
+ calls = 0;
+ disposed = 0;
+ throwOnce = false;
+
+ score(hop: Float32Array, phrases: readonly WakePhrase[]): Record {
+ this.calls += 1;
+ if (this.throwOnce) {
+ this.throwOnce = false;
+ throw new Error('inference exploded');
+ }
+ expect(hop.length).toBe(WAKE_HOP_SAMPLES);
+ expect(phrases.length).toBeGreaterThan(0);
+ return this.scores;
+ }
+
+ dispose(): void {
+ this.disposed += 1;
+ }
+}
+
+/** One 20 ms frame of non-silent audio. Content is irrelevant to the scorer. */
+function frame(fill = 1000): Int16Array {
+ return new Int16Array(ACAPPELLA_AUDIO_FRAME_SAMPLES).fill(fill);
+}
+
+/** Push enough frames to complete `hops` scoring windows. */
+function pushHops(detector: WakeDetector, hops: number, fill?: number): void {
+ const framesPerHop = WAKE_HOP_SAMPLES / ACAPPELLA_AUDIO_FRAME_SAMPLES;
+ for (let i = 0; i < hops * framesPerHop; i++) detector.pushFrame(frame(fill));
+}
+
+describe('wakeThresholdFor', () => {
+ it('inverts sensitivity and clamps nonsense into the band', () => {
+ expect(wakeThresholdFor({ id: 'a', phrase: 'a', scope: { kind: 'conductor' } }, 0.5)).toBe(0.5);
+ expect(
+ wakeThresholdFor({ id: 'a', phrase: 'a', scope: { kind: 'conductor' }, sensitivity: 0.9 })
+ ).toBeCloseTo(0.1);
+ // The most sensitive setting is still not a hair trigger.
+ expect(
+ wakeThresholdFor({ id: 'a', phrase: 'a', scope: { kind: 'conductor' }, sensitivity: 5 })
+ ).toBeGreaterThan(0);
+ });
+});
+
+describe('assertWakeScorerLocal', () => {
+ it('refuses a scorer that is not local', () => {
+ const hosted = { tier: 'cloud', score: () => ({}) } as unknown as WakePhraseScorer;
+ expect(() => assertWakeScorerLocal(hosted)).toThrow(/no audio may leave the machine/i);
+ });
+});
+
+describe('WakeDetector', () => {
+ let scorer: ScriptedScorer;
+ let detections: WakeDetection[];
+ let clock: number;
+
+ const phrases: WakePhrase[] = [
+ globalWakePhrase('hey maestro'),
+ agentWakePhrase('agent-7', 'hey scout'),
+ ];
+
+ function build(overrides: Partial[0]> = {}): WakeDetector {
+ return createWakeDetector({
+ getPhrases: () => phrases,
+ scorer,
+ onWake: (detection) => detections.push(detection),
+ now: () => clock,
+ ...overrides,
+ });
+ }
+
+ beforeEach(() => {
+ scorer = new ScriptedScorer();
+ detections = [];
+ clock = 1_000;
+ });
+
+ it('fires when a phrase clears its threshold', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.8 };
+
+ pushHops(detector, 1);
+
+ expect(detections).toHaveLength(1);
+ expect(detections[0].phrase).toBe('hey maestro');
+ expect(detections[0].scope).toEqual({ kind: 'conductor' });
+ });
+
+ it('stays quiet below the threshold', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.3 };
+
+ pushHops(detector, 3);
+
+ expect(detections).toHaveLength(0);
+ expect(detector.getStats().hopsScored).toBe(3);
+ });
+
+ it('resolves an agent phrase to that agent scope', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { 'agent:agent-7': 0.9 };
+
+ pushHops(detector, 1);
+
+ expect(detections[0].scope).toEqual({ kind: 'agent', sessionId: 'agent-7' });
+ expect(detections[0].phrase).toBe('hey scout');
+ });
+
+ it('fires the best match when two phrases clear at once', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.7, 'agent:agent-7': 0.95 };
+
+ pushHops(detector, 1);
+
+ // One session per sentence, and it is the closer match that wins.
+ expect(detections).toHaveLength(1);
+ expect(detections[0].phraseId).toBe('agent:agent-7');
+ });
+
+ it('debounces consecutive windows of one spoken phrase', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 4);
+
+ expect(detections).toHaveLength(1);
+ expect(detector.getStats().debounced).toBe(3);
+ });
+
+ it('fires again once the debounce window has passed', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 1);
+ clock += DEFAULT_WAKE_DEBOUNCE_MS + 1;
+ pushHops(detector, 1);
+
+ expect(detections).toHaveLength(2);
+ });
+
+ it('debounces per phrase, not globally', async () => {
+ const detector = build();
+ await detector.start();
+
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+ pushHops(detector, 1);
+ scorer.scores = { 'agent:agent-7': 0.9 };
+ pushHops(detector, 1);
+
+ expect(detections.map((d) => d.phraseId)).toEqual([GLOBAL_WAKE_PHRASE_ID, 'agent:agent-7']);
+ });
+
+ it('hands the pre-roll to the caller so the words after the phrase survive', async () => {
+ const detector = build({ preRollMs: 200 });
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 1);
+
+ expect(detections[0].preRoll.length).toBeGreaterThan(0);
+ expect(detections[0].preRoll[0]).toBeInstanceOf(Int16Array);
+ });
+
+ it('drains the pre-roll on a hit, so the same audio is never replayed twice', async () => {
+ const detector = build({ preRollMs: 200 });
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 1);
+ const first = detections[0].preRoll;
+ clock += DEFAULT_WAKE_DEBOUNCE_MS + 1;
+ pushHops(detector, 1);
+ const second = detections[1].preRoll;
+
+ expect(first.length).toBeGreaterThan(0);
+ expect(second.every((buffer) => !first.includes(buffer))).toBe(true);
+ });
+
+ it('uses an injected pre-roll ring, so the pipeline and the detector share one buffer', async () => {
+ const pushed: Int16Array[] = [];
+ const ring = {
+ push: (samples: Int16Array) => pushed.push(samples),
+ drain: () => pushed.splice(0, pushed.length),
+ clear: () => (pushed.length = 0),
+ };
+ const detector = build({ preRoll: ring });
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 1);
+
+ expect(detections[0].preRoll.length).toBe(WAKE_HOP_SAMPLES / ACAPPELLA_AUDIO_FRAME_SAMPLES);
+ expect(pushed).toHaveLength(0);
+ });
+
+ it('skips a phrase that is switched off', async () => {
+ const parked: WakePhrase[] = [{ ...globalWakePhrase('hey maestro'), enabled: false }];
+ const detector = build({ getPhrases: () => parked });
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.99 };
+
+ pushHops(detector, 2);
+
+ expect(detections).toHaveLength(0);
+ expect(scorer.calls).toBe(0);
+ });
+
+ it('reads the phrase list per hop, so a new agent phrase arms without a restart', async () => {
+ const live: WakePhrase[] = [globalWakePhrase('hey maestro')];
+ const detector = build({ getPhrases: () => live });
+ await detector.start();
+ scorer.scores = { 'agent:late': 0.95 };
+
+ pushHops(detector, 1);
+ expect(detections).toHaveLength(0);
+
+ live.push(agentWakePhrase('late', 'hey late'));
+ pushHops(detector, 1);
+ expect(detections).toHaveLength(1);
+ });
+
+ it('counts a scoring failure instead of throwing into the audio callback', async () => {
+ const detector = build();
+ await detector.start();
+ scorer.throwOnce = true;
+
+ expect(() => pushHops(detector, 1)).not.toThrow();
+ expect(detector.getStats().scoreErrors).toBe(1);
+ });
+
+ it('survives a wake handler that throws', async () => {
+ const detector = build({
+ onWake: () => {
+ throw new Error('session exploded');
+ },
+ });
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ expect(() => pushHops(detector, 1)).not.toThrow();
+ });
+
+ it('runs inert rather than failing when no scorer can be built', async () => {
+ const detector = createWakeDetector({
+ getPhrases: () => phrases,
+ createScorer: async () => null,
+ onWake: (detection) => detections.push(detection),
+ });
+ await detector.start();
+
+ expect(detector.isRunning).toBe(true);
+ expect(detector.isArmed).toBe(false);
+ pushHops(detector, 3);
+ expect(detections).toHaveLength(0);
+ });
+
+ it('refuses a scorer that is not local', () => {
+ const hosted = { tier: 'cloud', score: () => ({}) } as unknown as WakePhraseScorer;
+ expect(() =>
+ createWakeDetector({ getPhrases: () => phrases, scorer: hosted, onWake: vi.fn() })
+ ).toThrow(/no audio may leave the machine/i);
+ });
+
+ it('consumes nothing before start and nothing after stop', async () => {
+ const detector = build();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ pushHops(detector, 2);
+ expect(detector.getStats().framesReceived).toBe(0);
+
+ await detector.start();
+ await detector.stop();
+ pushHops(detector, 2);
+
+ expect(detections).toHaveLength(0);
+ // A scorer the caller supplied belongs to the caller, so stopping does not
+ // free it. Only one the detector built holds ONNX sessions to release.
+ expect(scorer.disposed).toBe(0);
+ });
+
+ it('disposes the scorer it built itself', async () => {
+ const built = new ScriptedScorer();
+ const detector = createWakeDetector({
+ getPhrases: () => phrases,
+ createScorer: async () => built,
+ onWake: vi.fn(),
+ });
+ await detector.start();
+ await detector.stop();
+
+ expect(built.disposed).toBe(1);
+ });
+
+ // -----------------------------------------------------------------------
+ // The invariant
+ // -----------------------------------------------------------------------
+
+ describe('no audio leaves the process while only the wake word is running', () => {
+ it('never hands a frame to a hosted speech provider', async () => {
+ const feed = vi.fn();
+ const hostedStt = {
+ id: 'openai-stt',
+ tier: 'cloud',
+ feed,
+ flush: vi.fn(),
+ start: vi.fn(),
+ stop: vi.fn(),
+ } as unknown as SttProvider;
+
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+
+ // 200 frames is four seconds of a room being listened to.
+ for (let i = 0; i < 200; i++) detector.pushFrame(frame(i * 7));
+
+ expect(feed).not.toHaveBeenCalled();
+ // And nothing the detector produced references the provider at all: the
+ // only outward edge is `onWake`, which hands PCM to the CALLER.
+ expect(hostedStt).toBeDefined();
+ expect(detector.getStats().framesReceived).toBe(200);
+ });
+
+ it('makes no network call of any kind', async () => {
+ const fetchSpy = vi.fn();
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
+ try {
+ const detector = build();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+ for (let i = 0; i < 200; i++) detector.pushFrame(frame(i));
+ expect(fetchSpy).not.toHaveBeenCalled();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it('clears the retained audio when it stops', async () => {
+ const detector = build({ preRollMs: 500 });
+ await detector.start();
+ pushHops(detector, 2, 5000);
+ await detector.stop();
+ await detector.start();
+ scorer.scores = { [GLOBAL_WAKE_PHRASE_ID]: 0.9 };
+ pushHops(detector, 1);
+
+ // Only this run's frames, never the previous run's.
+ expect(detections[0].preRoll.length).toBe(WAKE_HOP_SAMPLES / ACAPPELLA_AUDIO_FRAME_SAMPLES);
+ });
+ });
+});
diff --git a/src/__tests__/main/cue/cue-executor.test.ts b/src/__tests__/main/cue/cue-executor.test.ts
index dcee3c92f5..34254b784f 100644
--- a/src/__tests__/main/cue/cue-executor.test.ts
+++ b/src/__tests__/main/cue/cue-executor.test.ts
@@ -1492,6 +1492,49 @@ describe('cue-executor', () => {
expect(result.providerSessionId).toBe('sess-final');
});
+ it('still extracts the session id when the parser surfaces result text', async () => {
+ // Regression: `runProcess` hands back stdout that has ALREADY been
+ // through `extractCleanStdout`, which collapses a stream-json
+ // transcript down to its result text. Mining the session id out of
+ // that cleaned string finds nothing, because the JSON envelope
+ // carrying `session_id` is gone. The extraction must read the raw
+ // buffer instead.
+ //
+ // The sibling test above cannot catch this: its parser never sets
+ // `text`, so cleaning falls through and returns the raw NDJSON
+ // unchanged. A real agent parser DOES set `text` on the `result`
+ // event - which is exactly what erased the id in production and
+ // left `provider_session_id` NULL on every `cue_events` row.
+ mockGetOutputParser.mockReturnValue({
+ parseJsonLine: (line: string) => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ return null;
+ }
+ },
+ extractSessionId: (event: any) => event?.session_id ?? null,
+ } as any);
+
+ const ndjson = [
+ JSON.stringify({ type: 'system', session_id: 'sess-init' }),
+ JSON.stringify({ type: 'result', session_id: 'sess-final', text: 'All done.' }),
+ ].join('\n');
+
+ const config = createExecutionConfig({ toolType: 'claude-code' });
+ const resultPromise = executeCuePrompt(config);
+ await vi.advanceTimersByTimeAsync(0);
+
+ mockChild.stdout.emit('data', ndjson);
+ mockChild.emit('close', 0);
+ const result = await resultPromise;
+
+ // Cleaned stdout is just the result text - the id is only
+ // recoverable from the raw buffer.
+ expect(result.stdout).toBe('All done.');
+ expect(result.providerSessionId).toBe('sess-final');
+ });
+
it('returns null provider session id when no parser is registered', async () => {
// Plain-text agents / command runs have no parser to mine an id from.
mockGetOutputParser.mockReturnValue(null);
diff --git a/src/__tests__/main/debug-package/voice-runtime.test.ts b/src/__tests__/main/debug-package/voice-runtime.test.ts
new file mode 100644
index 0000000000..ec805ad7b5
--- /dev/null
+++ b/src/__tests__/main/debug-package/voice-runtime.test.ts
@@ -0,0 +1,105 @@
+/**
+ * The A Cappella voice-runtime collector.
+ *
+ * Contracts defended:
+ * - With the Encore Feature OFF, the self-test does not run and NO native
+ * runtime is loaded. This is the collector's half of "off means off": the
+ * self-test `dlopen`s every declared inference engine, and a debug package is
+ * built by users who are usually reporting something else entirely.
+ * - "Skipped" and "errored" are different fields. A reader of a support package
+ * has to be able to tell "we chose not to run it" from "it ran and blew up".
+ * - The static runtime table is reported either way: which binaries this build
+ * expects, on this platform, is the half of the answer worth having with the
+ * feature off.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type Store from 'electron-store';
+
+vi.mock('electron', () => ({
+ app: { getPath: () => '/tmp/acappella-voice-runtime-test' },
+ systemPreferences: { getMediaAccessStatus: () => 'granted', askForMediaAccess: vi.fn() },
+}));
+
+const selfTest = vi.hoisted(() => ({ run: vi.fn() }));
+
+vi.mock('../../../main/acappella/runtime/runtime-selftest', () => ({
+ runSelfTest: selfTest.run,
+}));
+
+import { collectVoiceRuntime } from '../../../main/debug-package/collectors/voice-runtime';
+
+/** The one key this collector reads. */
+function storeWith(aCappella: unknown): Store> {
+ return {
+ get: (key: string, defaultValue?: unknown) =>
+ key === 'encoreFeatures' ? { aCappella } : defaultValue,
+ } as unknown as Store