From f06bf0b1790d9e65bcedcafda21d4877b4b63328 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:15:55 -0500 Subject: [PATCH 1/8] =?UTF-8?q?fix(voice):=20wake-word=20survives=20audio-?= =?UTF-8?q?graph=20rebuild=20=E2=80=94=20closes=20#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cancelInFlight` was wired to `WakeWordDAG.cancel()`, which calls `streamCont.finish()` and permanently closes the public wake-word stream. Every audio-graph rebuild (device change, mic re-grant, ring overflow, AEC fallback) triggers `cancelInFlight` via `AudioGraphOwner.teardown`, so the wake path died on first rebuild event and never recovered. `stopFeed()` already existed for this case — it cancels the feed Task while preserving the public stream so consumers can iterate across the rebuild boundary. The fix is two-part: 1. `setCancelInFlight` now invokes `stopFeed()` instead of `cancel()`. 2. The rebuild-completion consumer now re-arms the DAG against the new ring (`wakeWordDAG.start(ring: newRing)`) before checking AEC variant — without this, the DAG's feed Task is gone after a rebuild and there's no producer for the surviving stream. Adds WD-3 regression test: drives a real RingBuffer + DAG through `stopFeed()` then `start(ring: newRing)` and asserts the consumer Task stays alive AND the new ring's marker samples drive the session. Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 23 ++++- .../Tests/VoiceTests/WakeWordDAGTests.swift | 89 +++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 471690c..52c529a 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -1333,9 +1333,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // `.reconfiguring(reason:)` on each rebuild start; the variant flip // is observable via `await graphOwner.currentVariant`. We poll the // variant after each rebuild event. - audioGraphRebuildTask = Task { @MainActor [weak self] in + // + // Re-arm the wake-word DAG against the new ring on every rebuild. + // The `cancelInFlight` slot (set below) calls `stopFeed()` to halt + // the old feed task; the public `wakeWordStream` survives, but the + // DAG has no producer until we call `start(ring:)` again. Without + // this, wake-word stops working after any device-change / + // mic-regrant / ring-overflow event. + // (audit-2026-05-12 P0-1 / Issue #28.) + audioGraphRebuildTask = Task { @MainActor [weak self, wakeWordDAG] in for await _ in rebuildStream { guard let self else { return } + if let newRing = await self.audioGraphOwner?.ringBuffer { + await wakeWordDAG.start(ring: newRing) + } let variant = await self.audioGraphOwner?.currentVariant if case .aecOn = variant { await self.voiceController?.handleAECRestored() @@ -1399,8 +1410,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Cancel wake-word inference before the engine stops — otherwise // the detached feed Task races the ring deallocation during graph // rebuilds. (VOICE-10.) + // + // Use `stopFeed()` (not `cancel()`) so the public wakeWordStream + // survives the rebuild — `VoiceController.spawnWakeWordConsumer` + // keeps iterating across the boundary. The rebuild completion + // consumer below re-arms the DAG against the new ring. + // (audit-2026-05-12 P0-1 / Issue #28: cancel() permanently + // finished the stream, killing wake-word on first device-change / + // mic-regrant / ring-overflow event.) await graphOwner.setCancelInFlight { [wakeWordDAG] in - await wakeWordDAG.cancel() + await wakeWordDAG.stopFeed() } // The production adapter triad replaces the three earlier Null diff --git a/packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift b/packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift index 6361024..c8e0219 100644 --- a/packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift +++ b/packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift @@ -116,6 +116,95 @@ final class WakeWordDAGTests: XCTestCase { } } + /// WD-3 (Issue #28): the DAG survives an audio-graph rebuild — + /// `stopFeed()` keeps the public stream alive, and `start(ring:)` + /// against a new ring resumes inference into the same stream. This + /// is the canonical "rebuild boundary" sequence AppDelegate now + /// wires (cancelInFlight → stopFeed; rebuildStream consumer → + /// start(ring: newRing)). + /// + /// Regression: prior to the fix, `cancelInFlight` called `cancel()` + /// which finished the stream — wake-word died on first + /// device-change / mic-regrant / ring-overflow event. + func test_DAG_survivesRebuild_streamStaysLive_newRingDrives() async throws { + let recorder = InputRecorder() + + let session = OpenWakeWordSession(scriptedClassifier: { input in + Task { await recorder.record(input) } + return 0.0 + }) + + // Initial ring + DAG arming. + let ring1 = RingBuffer(capacityFrames: 16000) + let markerA: [Float] = Array(repeating: 0.11, count: 1280) + writeSamples(markerA, into: ring1) + + let dag = WakeWordDAG(session: session) + await dag.start(ring: ring1) + + // Wait for at least one record from ring1. + let deadline1 = Date().addingTimeInterval(1.0) + while Date() < deadline1 { + if await recorder.count > 0 { break } + try? await Task.sleep(for: .milliseconds(20)) + } + let preRebuildCount = await recorder.count + XCTAssertGreaterThan( + preRebuildCount, 0, + "Pre-rebuild: DAG must drive the session from ring1" + ) + + // Spawn a consumer that exits only when the stream is finished — + // mirrors the production `VoiceController.spawnWakeWordConsumer`. + // We assert it stays running across the rebuild boundary. + actor ConsumerFlag { var exited = false; func set() { exited = true } } + let flag = ConsumerFlag() + let consumerTask = Task { + for await _ in dag.wakeWordStream { /* ignore events */ } + await flag.set() + } + + // Simulate AudioGraphOwner.teardown → cancelInFlight. + // The bug fix: stopFeed() (NOT cancel()) so the public stream + // survives. The consumer Task must NOT exit. + await dag.stopFeed() + try? await Task.sleep(for: .milliseconds(100)) + let exitedAfterStopFeed = await flag.exited + XCTAssertFalse( + exitedAfterStopFeed, + "stopFeed() must NOT finish the wake-word stream — the bug was cancel() finishing it" + ) + + // Re-arm against a NEW ring (simulating the new ring buffer from + // AudioGraphOwner's post-rebuild graph). + let priorCount = await recorder.count + let ring2 = RingBuffer(capacityFrames: 16000) + let markerB: [Float] = Array(repeating: 0.99, count: 1280) + writeSamples(markerB, into: ring2) + await dag.start(ring: ring2) + + // Wait for new ring's marker to drive the session. + let deadline2 = Date().addingTimeInterval(1.0) + var sawNewMarker = false + while Date() < deadline2 { + let snap = await recorder.snapshot + if snap.suffix(snap.count - priorCount).contains(where: { buf in + abs((buf.first ?? .nan) - 0.99) < 1e-5 + }) { + sawNewMarker = true + break + } + try? await Task.sleep(for: .milliseconds(20)) + } + await dag.cancel() + consumerTask.cancel() + + XCTAssertTrue( + sawNewMarker, + "After stopFeed() + start(ring: newRing), the DAG must drive the session from the new ring (rebuild boundary contract)" + ) + } + /// WD-2: when the ring is empty, the DAG should NOT call the /// classifier (current loop sleeps and retries when framesRead == 0). /// Locks in the contract that we don't burn CPU calling the -- 2.54.0 From 70739a2a89d7c101d1b136eb7b6b36f478498fd0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:25:04 -0500 Subject: [PATCH 2/8] =?UTF-8?q?fix(voice):=20STT=20backend=20reads=20PerTu?= =?UTF-8?q?rnSnapshot;=20selector=20constants=20unified=20=E2=80=94=20clos?= =?UTF-8?q?es=20#29?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled bugs prevented WhisperKit fallback from being reachable via config: 1. `installVoice` hard-coded the STT factory to `STTBackendSelector.make(backend: "speech_analyzer")`, ignoring `PerTurnSnapshot.stt.whisperKitFallback` entirely. Flipping the config flag changed nothing for real synthesis. 2. The `get_self_state` tool returned camelCase strings (`"speechAnalyzer"` / `"whisperKit"`) while `STTBackendSelector.make` accepts only snake_case (`"speech_analyzer"` / `"whisperkit"`). Any consumer routing the tool output back through the selector landed in the unknown-backend default branch — silent drift. Fix: - `VoiceController.sttFactory` is now `() async -> any STTProvider` so the closure can `await configStore.perTurn()` per session. Test sites using `{ stt }` syntax continue to compile because closures with no awaits are implicitly async-compatible. - AppDelegate's factory now reads `snap.stt.whisperKitFallback` and routes through the canonical `STTBackendSelector.backendSpeechAnalyzer` / `STTBackendSelector.backendWhisperKit` constants. - `get_self_state`'s `sttBackend` closure returns the same canonical constants so the tool output round-trips through the selector without falling into the default branch. Adds B5 regression test: asserts the canonical constants round-trip through `make(backend:)` without emitting an unknown-backend warning, and that the pre-fix camelCase strings still produce a warning (locks against silent drift if anyone re-introduces the mismatch). Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 33 ++++++++++++++-- .../Voice/Sources/Voice/VoiceController.swift | 6 +-- .../VoiceTests/STTBackendSwitchTests.swift | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 52c529a..9f546b1 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -1492,7 +1492,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let vc = VoiceController( wakeWordStream: wakeWordDAG.wakeWordStream, vadFactory: { sileroVAD }, - sttFactory: { STTBackendSelector.make(backend: "speech_analyzer") }, + sttFactory: { [configStore = self.configStore] in + // Read STT backend from PerTurnSnapshot. Defaults to + // SpeechAnalyzer when the snapshot disables WhisperKit + // fallback (today's default) or when configStore is nil. + // (audit-2026-05-12 P0-2 / Issue #29: was hard-coded to + // "speech_analyzer", making `stt.whisperKitFallback` + // unreachable from config.) + let backend: String + if let configStore { + let snap = await configStore.perTurn() + backend = snap.stt.whisperKitFallback + ? STTBackendSelector.backendWhisperKit + : STTBackendSelector.backendSpeechAnalyzer + } else { + backend = STTBackendSelector.backendSpeechAnalyzer + } + return STTBackendSelector.make(backend: backend) + }, tts: ttsAdapter, orchestrator: orchAdapter, bannerCoordinator: bannerAdapter, @@ -2281,9 +2298,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return snap.tts.tier } let sttBackend: @Sendable () async -> String = { - guard let configStore else { return "speechAnalyzer" } + // Returns the canonical backend identifier matching + // `STTBackendSelector.backendSpeechAnalyzer` / + // `STTBackendSelector.backendWhisperKit` (snake_case). The + // pre-2026-05-12 strings ("speechAnalyzer" / "whisperKit" + // camelCase) did not match the selector's accepted values — + // any consumer routing through `STTBackendSelector.make` + // landed in the `unknown backend` default branch. + // (audit-2026-05-12 P0-2 / P2-1 / Issue #29.) + guard let configStore else { return STTBackendSelector.backendSpeechAnalyzer } let snap = await configStore.perTurn() - return snap.stt.whisperKitFallback ? "whisperKit" : "speechAnalyzer" + return snap.stt.whisperKitFallback + ? STTBackendSelector.backendWhisperKit + : STTBackendSelector.backendSpeechAnalyzer } let wakeWordMuted: @Sendable () async -> Bool = { UserDefaults.standard.bool(forKey: "features.voice.wakeWordMuted") diff --git a/packages/Voice/Sources/Voice/VoiceController.swift b/packages/Voice/Sources/Voice/VoiceController.swift index ee42a6d..f5ee80e 100644 --- a/packages/Voice/Sources/Voice/VoiceController.swift +++ b/packages/Voice/Sources/Voice/VoiceController.swift @@ -48,7 +48,7 @@ public actor VoiceController { private let wakeWordStream: AsyncStream private let vadFactory: @Sendable () -> SileroVAD - private let sttFactory: @Sendable () -> any STTProvider + private let sttFactory: @Sendable () async -> any STTProvider private let chunkPump: @Sendable (AsyncStream.Continuation) async -> Void private let tts: any VoiceTTSInterface private let orchestrator: any VoiceOrchestratorInterface @@ -126,7 +126,7 @@ public actor VoiceController { public init( wakeWordStream: AsyncStream, vadFactory: @escaping @Sendable () -> SileroVAD, - sttFactory: @escaping @Sendable () -> any STTProvider, + sttFactory: @escaping @Sendable () async -> any STTProvider, tts: any VoiceTTSInterface, orchestrator: any VoiceOrchestratorInterface, bannerCoordinator: any VoiceBannerInterface, @@ -395,7 +395,7 @@ public actor VoiceController { sttSessionId &+= 1 let sessionId = sttSessionId - let provider = sttFactory() + let provider = await sttFactory() let (chunkStream, cont) = AsyncStream.makeStream() sttChunkCont = cont diff --git a/packages/Voice/Tests/VoiceTests/STTBackendSwitchTests.swift b/packages/Voice/Tests/VoiceTests/STTBackendSwitchTests.swift index dbf5e2f..5179a83 100644 --- a/packages/Voice/Tests/VoiceTests/STTBackendSwitchTests.swift +++ b/packages/Voice/Tests/VoiceTests/STTBackendSwitchTests.swift @@ -225,6 +225,45 @@ final class STTBackendSelectorTests: XCTestCase { XCTAssertTrue(stt is SpeechAnalyzerSTT, "Empty backend string should return SpeechAnalyzerSTT (default)") } + + // MARK: - B5 (Issue #29): canonical constants round-trip through `make` + + /// Regression: the canonical constants exported by `STTBackendSelector` + /// (snake_case `speech_analyzer` / `whisperkit`) must round-trip + /// through `make(backend:)` without falling into the unknown-backend + /// default branch. The 2026-05-12 audit found `get_self_state` + /// returning camelCase strings (`"speechAnalyzer"` / `"whisperKit"`), + /// which DO match the constants here only when callers use the + /// constants — string-literal mismatches landed in the default + /// branch silently. This test pins the contract. + func testB5_canonicalConstantsRoundTripThroughMake() { + let logger1 = TestWarningLogger() + let s1 = STTBackendSelector.make( + backend: STTBackendSelector.backendSpeechAnalyzer, + warningLogger: logger1 + ) + XCTAssertTrue(s1 is SpeechAnalyzerSTT) + XCTAssertNil(logger1.lastWarning, + "canonical 'speech_analyzer' constant must not log unknown-backend warning") + + let logger2 = TestWarningLogger() + let s2 = STTBackendSelector.make( + backend: STTBackendSelector.backendWhisperKit, + warningLogger: logger2 + ) + XCTAssertTrue(s2 is WhisperKitSTT) + XCTAssertNil(logger2.lastWarning, + "canonical 'whisperkit' constant must not log unknown-backend warning") + + // Anti-pattern: camelCase strings (the pre-fix `get_self_state` + // shape) must land in the default branch and emit a warning. + let logger3 = TestWarningLogger() + let s3 = STTBackendSelector.make(backend: "whisperKit", warningLogger: logger3) + XCTAssertTrue(s3 is SpeechAnalyzerSTT, + "camelCase 'whisperKit' must fall through to default") + XCTAssertNotNil(logger3.lastWarning, + "camelCase 'whisperKit' must produce an unknown-backend warning (lock against silent drift)") + } } // MARK: - Test doubles -- 2.54.0 From 5a38b5fcf2743b610a09666a6258535739b2fd01 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:34:29 -0500 Subject: [PATCH 3/8] =?UTF-8?q?fix(voice):=20TTS=20emits=20.ttsStopped=20o?= =?UTF-8?q?n=20natural=20completion=20=E2=80=94=20closes=20#30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this fix, `TTSEngineActor.synthesize` yielded `.started → .firstAudio → .finished` and never `.ttsStopped`. The only emit site was `InterruptSequence.run` step 5, fired only on barge-in. `TTSEvent.swift` and the `TTSEngineActor` header both documented the canonical sequence as `... → .finished → .ttsStopped`, and the I4 ducking test in `TTSInterruptTests` validates ducking releases specifically on `.ttsStopped` — not `.finished`. The contract was structurally broken: any future ducking consumer (none exists in production today, but M-6 will wire one) would have left the input duck engaged forever after a normal spoken reply. Two-emit-site model: - Natural completion: `synthesize` now yields `.ttsStopped` after `await task.value` returns. Cancellation flows through `InterruptSequence.run` instead — no double-emit. - Barge-in: `InterruptSequence.run` step 5 unchanged. Loosens the InterruptSequence docstring from "ONLY site that emits .ttsStopped" to describe the two-site cooperation, and updates the TTSEngineActor header to match. The grep gate referenced in the prior docstring was a comment-only contract — no CI gate file existed — so no script changes. Adds TET1-5 regression test: real AVSpeechSynthesizer + tier-1 synthesize, collector drains until `.ttsStopped`, asserts `.finished` precedes `.ttsStopped` in the event stream. Co-Authored-By: Claude Opus 4.7 --- .../Sources/Voice/TTS/TTSEngineActor.swift | 28 ++++++++++-- .../Sources/Voice/TTS/TTSInterrupt.swift | 24 +++++++--- .../VoiceTests/TTSEngineActorTier1Tests.swift | 44 +++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift b/packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift index a734c72..cf16525 100644 --- a/packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift +++ b/packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift @@ -19,7 +19,11 @@ import MLXAudioCore // - fallback: TTSKit (feature-flag gated, defaulted off; Plan 06-05 wires flag). // // Event stream: `ttsEventStream` emits TTSEvent values for HUD animation + -// ducking control. Ducking releases ONLY on `.ttsStopped` (VOICE-11 / Pitfall #6). +// ducking control. Ducking releases on `.ttsStopped` — emitted both on +// natural completion (after `.finished`, inside `synthesize`) and on +// barge-in (via `InterruptSequence.run`). Prior to audit-2026-05-12 P0-3 +// (Issue #30) only the barge-in site emitted `.ttsStopped`, so natural +// completion left any future ducking consumer with ducks engaged forever. public actor TTSEngineActor { @@ -27,8 +31,17 @@ public actor TTSEngineActor { /// Event stream for HUD animation and ducking control. /// - /// VOICE-11 / Pitfall #6: Ducking releases ONLY on `.ttsStopped`. - /// DO NOT release ducking on `.finished` — producer done ≠ sink empty. + /// VOICE-11 / Pitfall #6: Ducking releases on `.ttsStopped`, + /// NOT on `.finished` — producer done ≠ sink empty. + /// `.ttsStopped` is emitted from two sites: + /// - Natural completion: `synthesize(...)` yields it after + /// `.finished` once the tier path has fully drained. + /// - Barge-in: `InterruptSequence.run(...)` yields it at step 5 + /// after the 5-step interrupt sequence has run. + /// (audit-2026-05-12 P0-3 / Issue #30 added the natural-completion + /// emit; before then only barge-in fired `.ttsStopped`, so any + /// ducking consumer would have left ducks engaged forever on a + /// normal spoken reply.) public nonisolated let ttsEventStream: AsyncStream // MARK: - Private state @@ -165,6 +178,8 @@ public actor TTSEngineActor { try await task.value } catch is CancellationError { _hasSynthInFlight = false + // Cancellation flows through `InterruptSequence.run`, which + // emits `.ttsStopped` at step 5. Don't double-emit here. throw TTSError.cancelled } catch let e as TTSError { _hasSynthInFlight = false @@ -175,6 +190,13 @@ public actor TTSEngineActor { } _hasSynthInFlight = false currentTask = nil + + // Natural-completion `.ttsStopped` emit (audit-2026-05-12 P0-3 / + // Issue #30). The barge-in path emits `.ttsStopped` from + // `InterruptSequence.run` step 5 instead. Together these are + // the two sites that release ducking — see `ttsEventStream` + // doc-comment. + eventContinuation.yield(.ttsStopped) } // MARK: - Cancel diff --git a/packages/Voice/Sources/Voice/TTS/TTSInterrupt.swift b/packages/Voice/Sources/Voice/TTS/TTSInterrupt.swift index 6684861..c3eb284 100644 --- a/packages/Voice/Sources/Voice/TTS/TTSInterrupt.swift +++ b/packages/Voice/Sources/Voice/TTS/TTSInterrupt.swift @@ -40,9 +40,19 @@ public protocol InterruptStepRecorder: Sendable { // 5. Emit `.ttsStopped` — THIS is the ducking-release gate // // VOICE-11 / Pitfall #6: -// `.ttsStopped` at step 5 is the ONLY site that triggers ducking release. -// DO NOT release ducking on `.finished` — producer done ≠ sink empty. -// The 100–200 ms tail of scheduled buffers would clip if ducking released early. +// `.ttsStopped` is the ducking-release gate (NEVER `.finished` — +// producer done ≠ sink empty; the 100–200 ms tail of scheduled +// buffers would clip if ducking released early). +// +// Two emit sites cooperate to cover the full lifecycle: +// - Natural completion: `TTSEngineActor.synthesize` yields it +// after `.finished` when the tier path has fully drained. +// - Barge-in: this `InterruptSequence.run` yields it at step 5 +// after the 5-step interrupt sequence has run. +// +// Before audit-2026-05-12 P0-3 (Issue #30) only this barge-in site +// emitted `.ttsStopped`, so natural completion left any future +// ducking consumer with ducks engaged forever on a normal reply. // // Idempotency: // If `engine.hasSynthInFlight == false`, the sequence is a no-op. @@ -92,10 +102,10 @@ public enum InterruptSequence { group.cancelAll() } - // STEP 5: Emit .ttsStopped — THE ducking-release gate (VOICE-11 / Pitfall #6). - // This is the ONLY site that emits .ttsStopped (single source of truth). - // grep gate: grep -nE '\.ttsStopped' TTSInterrupt.swift | grep -v '//' | wc -l == 1 + // STEP 5: Emit .ttsStopped — the barge-in ducking-release gate. + // (Natural completion emits the corresponding `.ttsStopped` from + // `TTSEngineActor.synthesize`; see the file-level note above.) stepLog?.record("5-ttsStopped") - eventBus.yield(.ttsStopped) // single .ttsStopped emission site + eventBus.yield(.ttsStopped) } } diff --git a/packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift b/packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift index 2068a30..158b511 100644 --- a/packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift +++ b/packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift @@ -107,6 +107,50 @@ final class TTSEngineActorTier1Tests: XCTestCase { } } + /// TET1-5 (Issue #30): natural completion emits `.ttsStopped` after + /// `.finished` so any downstream ducking consumer can release the + /// input duck. Before the fix only the barge-in path + /// (`InterruptSequence.run`) yielded `.ttsStopped`, so a normal + /// spoken reply would have left ducking engaged forever. + func test_TET1_5_naturalCompletion_emitsTTSStopped() async throws { + let engine = TTSEngineActor( + orpheus: nil, + tier1: AVSpeechSynth(), + fallback: nil + ) + + // Collector that does NOT short-circuit on `.finished` — it must + // see `.ttsStopped` after `.finished` and exit on `.ttsStopped`. + let stream = engine.ttsEventStream + let collector = Task.detached { + var got: [TTSEvent] = [] + let deadline = Date().addingTimeInterval(4.0) + for await ev in stream { + got.append(ev) + if case .ttsStopped = ev { break } + if Date() > deadline { break } + } + return got + } + + try await engine.synthesize("ok", tier: .tier1, voice: "en-US") + + let collected = await collector.value + // Assert ordering: `.finished` must precede `.ttsStopped`. + let finishedIdx = collected.firstIndex(where: { + if case .finished = $0 { return true } else { return false } + }) + let stoppedIdx = collected.firstIndex(where: { + if case .ttsStopped = $0 { return true } else { return false } + }) + XCTAssertNotNil(finishedIdx, "expected .finished in event stream; got \(collected)") + XCTAssertNotNil(stoppedIdx, + "natural completion must emit .ttsStopped after .finished (audit-2026-05-12 P0-3 / Issue #30); got \(collected)") + if let f = finishedIdx, let s = stoppedIdx { + XCTAssertLessThan(f, s, ".finished must precede .ttsStopped") + } + } + /// TET1-4: rapid-fire tier-1 calls cancel prior in-flight synthesis. /// The actor's serial executor + reentrancy guard handles this. func test_TET1_4_rapidFire_cancelsPriorSynthesis() async throws { -- 2.54.0 From a847f1d129cbbf211888705bfa380e1ce7799a80 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:38:09 -0500 Subject: [PATCH 4/8] =?UTF-8?q?fix(voice):=20wire=20VoiceTTSAdapter.tierRe?= =?UTF-8?q?solver=20to=20PerTurnSnapshot=20=E2=80=94=20closes=20#31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AppDelegate.installVoice` constructed the adapter with `engine:` only, leaving `tierResolver` at the default `{ .tier1 }` constant. Setting `tts.tier = "tier2"` in config did nothing for the real synthesis path — only for the `get_self_state` tool which reads `snap.tts.tier` via a separate closure. Today this is decorative (Orpheus weights aren't shipped, so `TTSEngineActor.synthesize` resolves tier-2 back to tier-1 internally). The moment weights arrive, however, the user would have had no way to flip the tier from config — they'd have needed a code change. The fix lands the wiring now so the config flag is live the day Orpheus is. Adds a regression test that asserts `tierResolver` is invoked once per `synthesize` call on the adapter. Engine is a real tier-1 `TTSEngineActor`; the resolver is a counter-incrementing closure. Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 17 ++++++- .../VoiceOrchestratorAdapterTests.swift | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 9f546b1..ad766d5 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -1457,7 +1457,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // wires alone here; tier-2 (Orpheus) is gated behind the ~6 GB // weight download and degrades to tier-1 transparently. // (audit trail: 2026-05-03 voice audit / Track B-3.) - let ttsAdapter = VoiceTTSAdapter(engine: VoiceOutputWiring.makeTier1Engine()) + // Resolve TTS tier from PerTurnSnapshot per synthesis. Defaults + // to `.tier1` when configStore is nil or `tts.tier` is anything + // other than "tier2". Today this is decorative — Orpheus weights + // aren't shipped, so the engine resolves tier-2 back to tier-1 + // inside `synthesize` — but the moment weights arrive, this + // closure becomes the user-facing tier switch. + // (audit-2026-05-12 P0-4 / Issue #31: was unwired; the default + // `{ .tier1 }` constant masked any `tts.tier = "tier2"` config.) + let ttsAdapter = VoiceTTSAdapter( + engine: VoiceOutputWiring.makeTier1Engine(), + tierResolver: { [configStore = self.configStore] in + guard let configStore else { return .tier1 } + let snap = await configStore.perTurn() + return snap.tts.tier == "tier2" ? .tier2 : .tier1 + } + ) // Reuse the `OutboundBatcher` constructed by `installAgent`. The // batcher coalesces high-frequency audio-level RMS at ~30 Hz diff --git a/App/Tests/AppTests/VoiceOrchestratorAdapterTests.swift b/App/Tests/AppTests/VoiceOrchestratorAdapterTests.swift index 6f159b2..a3e0bc6 100644 --- a/App/Tests/AppTests/VoiceOrchestratorAdapterTests.swift +++ b/App/Tests/AppTests/VoiceOrchestratorAdapterTests.swift @@ -102,6 +102,54 @@ final class VoiceOrchestratorAdapterTests: XCTestCase { await adapter.cancelTTS() } + // MARK: - VoiceTTSAdapter tierResolver wiring (Issue #31) + + /// Regression for audit-2026-05-12 P0-4 / Issue #31: AppDelegate + /// constructed the adapter with `engine:` only, leaving + /// `tierResolver` at the default `{ .tier1 }` constant. Setting + /// `tts.tier = "tier2"` in config did nothing for real synthesis. + /// The fix wires a closure that reads `PerTurnSnapshot.tts.tier`. + /// + /// This test asserts the adapter calls its resolver on every + /// `synthesize` call, so a config flip on the next synthesis lands. + /// The engine is nil (no real synthesis runs); we just measure that + /// `synthesize` *would have* asked the resolver before routing. + /// + /// With `engine: nil` the adapter no-ops before invoking the + /// resolver, so we use a non-nil engine sentinel. The simplest + /// stand-in is `TTSEngineActor(orpheus: nil, tier1: AVSpeechSynth(), + /// fallback: nil)` — a real tier-1 engine. We synthesize an empty + /// string to keep the test fast. + func testVoiceTTSAdapterTierResolverIsInvokedPerSynthesis() async throws { + actor ResolverCallCount { + var count = 0 + func tick() { count += 1 } + } + let counter = ResolverCallCount() + + let engine = TTSEngineActor( + orpheus: nil, + tier1: AVSpeechSynth(), + fallback: nil + ) + let adapter = VoiceTTSAdapter( + engine: engine, + tierResolver: { + await counter.tick() + return .tier1 + } + ) + + await adapter.synthesize("a") + await adapter.synthesize("b") + + let calls = await counter.count + XCTAssertEqual( + calls, 2, + "tierResolver must be invoked once per `synthesize` call (Issue #31). Got \(calls)." + ) + } + // MARK: - VoiceBusEmitterAdapter (forwards to batcher) func testVoiceBusEmitterAdapterForwardsRMSToBatcher() async throws { -- 2.54.0 From 307e3aa10e7a65c0eda4b71b4f826827d7c2b2fe Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:42:37 -0500 Subject: [PATCH 5/8] =?UTF-8?q?fix(voice):=20bound=20OpenWakeWordSession?= =?UTF-8?q?=20streaming=20buffers=20=E2=80=94=20closes=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpenWakeWordSession.runORT` appended to `melFrameBuffer` and `embeddingBuffer` on every `feed()` call without ever trimming. At one mel frame per 80 ms feed (production cadence), 24 h of always-on listening accumulated: - ~1.08M mel frames × 32 floats × 4 B ≈ 130 MB - ~135k embeddings × 96 × 4 B ≈ 50 MB The leak was masked by P0-1 (#28) because `WakeWordDAG` cancelled and rebuilt on every audio-graph rebuild, dropping the session along with the buffers. Closing #28 (stream survives rebuild, feed re-arms against new ring) makes the session — and the buffers — live for the process lifetime. Fix the bound now or memory grows unbounded. Rolling-window trim inside `runORT` after the embedding-advance loop: - Keep last `76 + embStride` mel frames (= 84) so the next embedding window has full context after trim. - Keep last 16 embeddings to match the classifier's `suffix(16)` read. - Slide `nextEmbStart` left by the drop count when mel frames are trimmed so the index stays valid (it indexes into the trimmed buffer). Clamp at 0. Adds B1/B2 regression tests via two internal test seams (`_testInjectFrame`, `_testBufferSizes`) that exercise the same append + advance + trim logic without requiring real ORT models. B1 runs 10 000 simulated frames and asserts both buffers stay at the documented bounds; B2 asserts the trim does NOT kick in for short streams (no premature context loss). Co-Authored-By: Claude Opus 4.7 --- .../Voice/WakeWord/OpenWakeWordSession.swift | 68 +++++++++++++ .../OpenWakeWordBufferBoundsTests.swift | 98 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 packages/Voice/Tests/VoiceTests/OpenWakeWordBufferBoundsTests.swift diff --git a/packages/Voice/Sources/Voice/WakeWord/OpenWakeWordSession.swift b/packages/Voice/Sources/Voice/WakeWord/OpenWakeWordSession.swift index 9e46d37..f083be7 100644 --- a/packages/Voice/Sources/Voice/WakeWord/OpenWakeWordSession.swift +++ b/packages/Voice/Sources/Voice/WakeWord/OpenWakeWordSession.swift @@ -192,6 +192,35 @@ public actor OpenWakeWordSession { nextEmbStart += embStride } + // Rolling-window trim (audit-2026-05-12 P1-1 / Issue #32). The + // streaming path appended to both buffers append-only — at one + // mel frame per 80 ms `feed` (production cadence), 24 h of + // always-on listening accumulated ~130 MB of mel frames + ~50 MB + // of embeddings retained forever. The leak was masked today by + // `WakeWordDAG` cancelling on every audio-graph rebuild, but + // closing #28 exposes it: fix one without the other and memory + // grows unbounded across long sessions. + // + // Trim policy: + // - Keep last `76 + embStride` mel frames so the next embedding + // window has full context after the trim. + // - Keep last 16 embeddings to match the classifier's + // `suffix(16)` read. + // - When mel frames are dropped, slide `nextEmbStart` left by + // the same count so indexing remains valid (it indexes into + // `melFrameBuffer`). + let melKeep = 76 + embStride + if melFrameBuffer.count > melKeep { + let drop = melFrameBuffer.count - melKeep + melFrameBuffer.removeFirst(drop) + // Shift `nextEmbStart` to stay aligned with the trimmed buffer. + // Clamp at 0 in case more was dropped than the prior index. + nextEmbStart = max(0, nextEmbStart - drop) + } + if embeddingBuffer.count > 16 { + embeddingBuffer.removeFirst(embeddingBuffer.count - 16) + } + // Run classifier on the most recent 16 embeddings. guard embeddingBuffer.count >= 16 else { return 0 // not enough context yet — below threshold by definition @@ -384,6 +413,45 @@ public actor OpenWakeWordSession { /// 96-dim embeddings accumulated across `feed()` calls. The classifier /// reads the most recent 16. private var embeddingBuffer: [[Float]] = [] + + // MARK: - Test seams (internal — for Issue #32 unbounded-growth tests) + + /// Test seam: simulate the streaming-buffer append + trim path without + /// requiring real ORT models. Mirrors the post-`runMel` / + /// post-`runEmbedding` writes in `runORT`, then applies the same + /// rolling-window trim. The classifier read is skipped — the caller + /// is testing buffer bounds, not detection. + /// + /// Use only from tests; production must go through `feed(samples:)`. + internal func _testInjectFrame( + mels: [[Float]], + embeddingsToAppend: [[Float]] = [] + ) { + melFrameBuffer.append(contentsOf: mels) + for emb in embeddingsToAppend { + embeddingBuffer.append(emb) + } + // Mirror `runORT`'s `nextEmbStart` advance + trim, so a stream of + // injected frames behaves like the production loop without ORT. + let embStride = 8 + while melFrameBuffer.count >= 76 + nextEmbStart { + nextEmbStart += embStride + } + let melKeep = 76 + embStride + if melFrameBuffer.count > melKeep { + let drop = melFrameBuffer.count - melKeep + melFrameBuffer.removeFirst(drop) + nextEmbStart = max(0, nextEmbStart - drop) + } + if embeddingBuffer.count > 16 { + embeddingBuffer.removeFirst(embeddingBuffer.count - 16) + } + } + + /// Test seam: read current streaming-buffer sizes for bound assertions. + internal var _testBufferSizes: (melFrames: Int, embeddings: Int, nextEmbStart: Int) { + (melFrameBuffer.count, embeddingBuffer.count, nextEmbStart) + } } // MARK: - Supporting types diff --git a/packages/Voice/Tests/VoiceTests/OpenWakeWordBufferBoundsTests.swift b/packages/Voice/Tests/VoiceTests/OpenWakeWordBufferBoundsTests.swift new file mode 100644 index 0000000..23cc36a --- /dev/null +++ b/packages/Voice/Tests/VoiceTests/OpenWakeWordBufferBoundsTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import Voice + +/// Coverage for the rolling-window trim of `OpenWakeWordSession`'s +/// streaming buffers (audit-2026-05-12 P1-1 / Issue #32). +/// +/// Pre-fix: `melFrameBuffer` and `embeddingBuffer` were append-only. +/// At production cadence (1 mel frame per 80 ms `feed`), 24 h of +/// always-on listening accumulated ~130 MB of mel frames + ~50 MB of +/// embeddings retained forever. The leak was masked because +/// `WakeWordDAG` cancelled on every audio-graph rebuild; closing +/// #28 (rebuild no longer kills wake-word) makes the leak visible. +/// +/// Post-fix invariants: +/// - `melFrameBuffer.count <= 76 + embStride` (= 84) +/// - `embeddingBuffer.count <= 16` +/// - `nextEmbStart` never points past `melFrameBuffer.count` +/// (it is shifted left when frames are trimmed) +final class OpenWakeWordBufferBoundsTests: XCTestCase { + + /// Construct a session with the scripted-classifier init so we can + /// inject frames without requiring ORT model files. + private func makeSession() -> OpenWakeWordSession { + return OpenWakeWordSession(scriptedClassifier: { _ in 0.0 }) + } + + /// Synthetic mel frame: 32 floats (correct band count for the + /// openWakeWord mel stage). Value content is irrelevant for the + /// trim — only counts and indices matter. + private func makeFrame() -> [Float] { + return [Float](repeating: 0.0, count: 32) + } + + /// Synthetic 96-dim embedding (matches the embedding stage output). + private func makeEmbedding() -> [Float] { + return [Float](repeating: 0.0, count: 96) + } + + /// B1: 10 000 simulated frames must leave both buffers bounded. + /// Pre-fix this would have stored all 10 000 mel frames + ~10 000 + /// embeddings. + func test_B1_streamingBuffersStayBoundedUnderHeavyLoad() async { + let session = makeSession() + + // Inject 10 000 single-frame batches with one embedding each. + for _ in 0..<10_000 { + await session._testInjectFrame( + mels: [makeFrame()], + embeddingsToAppend: [makeEmbedding()] + ) + } + + let sizes = await session._testBufferSizes + XCTAssertLessThanOrEqual( + sizes.melFrames, 76 + 8, + "melFrameBuffer must be bounded at 76 + embStride (= 84) after rolling-window trim" + ) + XCTAssertLessThanOrEqual( + sizes.embeddings, 16, + "embeddingBuffer must be bounded at 16 after rolling-window trim" + ) + XCTAssertGreaterThanOrEqual( + sizes.nextEmbStart, 0, + "nextEmbStart must remain non-negative after trim" + ) + XCTAssertLessThanOrEqual( + sizes.nextEmbStart, sizes.melFrames, + "nextEmbStart must remain a valid index into the trimmed melFrameBuffer" + ) + } + + /// B2: short-stream case (fewer than the trim threshold) must keep + /// every frame — the trim must only kick in once we exceed the + /// rolling-window bound. This pins the contract that the trim + /// preserves classifier context. + func test_B2_shortStreamRetainsAllFrames() async { + let session = makeSession() + + // Inject 10 mel frames + 5 embeddings — well below the trim + // thresholds (84 / 16). Expect both to be retained verbatim. + for _ in 0..<10 { + await session._testInjectFrame(mels: [makeFrame()]) + } + for _ in 0..<5 { + await session._testInjectFrame(mels: [], embeddingsToAppend: [makeEmbedding()]) + } + + let sizes = await session._testBufferSizes + XCTAssertEqual( + sizes.melFrames, 10, + "short streams below the trim threshold must keep every mel frame" + ) + XCTAssertEqual( + sizes.embeddings, 5, + "short streams below the trim threshold must keep every embedding" + ) + } +} -- 2.54.0 From 08a1ac8673b4a5324e86b6667f131b66c686532a Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:52:36 -0500 Subject: [PATCH 6/8] =?UTF-8?q?fix(voice):=20add=20wake-word=20/=20STT=20/?= =?UTF-8?q?=20TTS=20watchdog=20probes=20=E2=80=94=20closes=20#33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VoiceBootHealthProbe` only checked `audioGraphOwner` non-nil + `micStatus == .authorized` + `currentVariant != nil`. It reported `state=ok` even after: - the wake-word DAG died (P0-1 / #28 — pre-fix the DAG was cancelled on every audio-graph rebuild and never re-armed) - SpeechAnalyzer assets were missing (silent `assetUnavailable` on first transcribe) - TTS was never wired (pre-Track-B-3 `VoiceTTSAdapter(engine: nil)` no-op'd every synth call) The probe was useless for incident triage of the very bugs the v0.1 audit cluster targets. Three new watchdog probes fill the gap: - `WakeWordBootHealthProbe` — asserts the DAG's feed Task is armed via the new `WakeWordDAG.isFeedArmed` property. `.failed` loud when the DAG is installed but not feeding; `.unknown` soft when never installed (models absent, mic denied). - `STTBootHealthProbe` — reads `SFSpeechRecognizer.authorizationStatus()` as a proxy for SpeechAnalyzer asset availability. Synchronous read, no TCC prompt. - `TTSBootHealthProbe` — asserts `VoiceTTSAdapter.hasEngine` (engine not nil). `.degraded` loud when the adapter is dormant. AppDelegate gains two new stored handles (`wakeWordDAG`, `voiceTTSAdapter`) so the probes can query the live subsystem on each probe pass. The handles are nil-safe — probes report `.unknown` when the install path short-circuited. Adds W1-W3 / T1-T2 / S1 unit tests covering the three watchdog shapes plus the canonical post-P0-1 (DAG dead) and pre-Track-B-3 (TTS dormant) regression scenarios. Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 37 ++++++ App/Boot/BootHealthProbes.swift | 120 ++++++++++++++++++ .../AppTests/VoiceWatchdogProbesTests.swift | 91 +++++++++++++ App/Voice/VoiceTTSAdapter.swift | 5 + .../Sources/Voice/WakeWord/WakeWordDAG.swift | 12 ++ 5 files changed, 265 insertions(+) create mode 100644 App/Tests/AppTests/VoiceWatchdogProbesTests.swift diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index ad766d5..440d99e 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -561,6 +561,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// `emitTurnEnded` / `emitError` hooks. (Plan 09-04.) private var voiceOrchestratorAdapter: VoiceOrchestratorAdapter? + /// Stored handle to the wake-word DAG so `WakeWordBootHealthProbe` + /// (audit-2026-05-12 P1-2 / Issue #33) can query `isFeedArmed`. + /// Nil before `installVoice` succeeds (model files missing, etc.). + private var wakeWordDAG: WakeWordDAG? + + /// Stored handle to the production TTS adapter so + /// `TTSBootHealthProbe` can query whether a real `TTSEngineActor` + /// is wired. Nil before `installVoice` succeeds. + /// (audit-2026-05-12 P1-2 / Issue #33.) + private var voiceTTSAdapter: VoiceTTSAdapter? + /// Drains the broadcaster's `.voice` priority subscription. Maintains /// a per-turn assistant-text accumulator gated by /// `agentOrchestrator.turnSourceWasVoice` — text-originated turns @@ -1069,6 +1080,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { VoiceBootHealthProbe(audioGraphOwner: self.audioGraphOwner) ) + // Three voice-watchdog probes — fill the gap surfaced by + // audit-2026-05-12 P1-2 / Issue #33. `VoiceBootHealthProbe` + // alone checked `audioGraphOwner` + `micStatus` + `currentVariant` + // and reported `ok` even after the wake-word DAG died (P0-1) or + // TTS was never wired (Track B-3 pre-fix). These probes + // distinguish "voice is plumbed" from "voice will actually work". + await bootHealthOrchestrator.register( + WakeWordBootHealthProbe(isArmed: { [weak self] in + guard let dag = await MainActor.run(body: { self?.wakeWordDAG }) + else { return nil } + return await dag.isFeedArmed + }) + ) + + await bootHealthOrchestrator.register(STTBootHealthProbe()) + + await bootHealthOrchestrator.register( + TTSBootHealthProbe(engineAlive: { [weak self] in + guard let adapter = await MainActor.run(body: { self?.voiceTTSAdapter }) + else { return false } + return await adapter.hasEngine + }) + ) + await bootHealthOrchestrator.register(VisionBootHealthProbe()) await bootHealthOrchestrator.register( @@ -1297,6 +1332,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let wakeWordDAG = WakeWordDAG(session: wakeWordSession) + self.wakeWordDAG = wakeWordDAG // expose to BootHealthProbe (Issue #33) // The degradation + rebuild consumer Tasks MUST be spawned before // `graphOwner.open()` because `open()` may immediately yield @@ -1473,6 +1509,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return snap.tts.tier == "tier2" ? .tier2 : .tier1 } ) + self.voiceTTSAdapter = ttsAdapter // expose to BootHealthProbe (Issue #33) // Reuse the `OutboundBatcher` constructed by `installAgent`. The // batcher coalesces high-frequency audio-level RMS at ~30 Hz diff --git a/App/Boot/BootHealthProbes.swift b/App/Boot/BootHealthProbes.swift index bb32a38..e48854c 100644 --- a/App/Boot/BootHealthProbes.swift +++ b/App/Boot/BootHealthProbes.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import AVFoundation +import Speech import AgentCore import Bus import Config @@ -265,6 +266,125 @@ public struct VoiceBootHealthProbe: BootHealthProbe { } } +// MARK: - WakeWord (Issue #33) + +/// Watchdog for the wake-word DAG. Status `.ok` only when the feed Task +/// is armed against a ring. `.failed` when the DAG is dormant — fires +/// after audit-2026-05-12 P0-1's silent-death scenario (DAG cancelled +/// via `cancel()` and never re-armed). +/// +/// Constructed against an optional `@Sendable` closure so the AppDelegate +/// can resolve the DAG lazily — `installVoice` may not have constructed +/// the DAG yet at probe-registration time (model files absent), and we +/// want one probe shape across "DAG armed" / "DAG dormant" / "DAG +/// never installed". +public struct WakeWordBootHealthProbe: BootHealthProbe { + public let name = "wake_word" + private let isArmed: @Sendable () async -> Bool? + + /// - Parameter isArmed: closure returning `true` when the DAG's + /// feed Task is armed, `false` when stopped, `nil` when the DAG + /// was never installed (e.g. models missing). + public init(isArmed: @escaping @Sendable () async -> Bool?) { + self.isArmed = isArmed + } + + public func probe() async -> ProbeOutcome { + guard let armed = await isArmed() else { + return ProbeOutcome( + status: .unknown(reason: "wake-word DAG not installed (models absent or mic denied)", severity: .soft), + evidence: "WakeWordDAG handle unavailable" + ) + } + if armed { + return ProbeOutcome(status: .ok, evidence: "feed Task armed") + } + return ProbeOutcome( + status: .failed(reason: "wake-word DAG feed Task not armed", severity: .loud), + evidence: "WakeWordDAG.isFeedArmed=false — wake-word will not fire until re-armed" + ) + } +} + +// MARK: - STT (Issue #33) + +/// Probes Apple Speech recognition authorization status as a proxy for +/// SpeechAnalyzer asset availability. macOS 26 SpeechAnalyzer requires +/// the `com.apple.developer.speech-recognition-assets` entitlement + +/// `NSSpeechRecognitionAssetsUsageDescription` Info.plist key for +/// on-device asset download; missing either causes silent +/// `SFSpeechErrorCode.assetUnavailable` on first transcription. +/// +/// `SFSpeechRecognizer.authorizationStatus()` returns synchronously +/// without TCC prompts (it just reads the current state), so this +/// probe is safe to run at boot. +public struct STTBootHealthProbe: BootHealthProbe { + public let name = "stt" + + public init() {} + + public func probe() async -> ProbeOutcome { + let status = SFSpeechRecognizer.authorizationStatus() + switch status { + case .authorized: + return ProbeOutcome( + status: .ok, + evidence: "SFSpeechRecognizer authorized" + ) + case .denied, .restricted: + return ProbeOutcome( + status: .degraded(reason: "speech recognition TCC denied", severity: .loud), + evidence: "SFSpeechRecognizer status=\(String(describing: status))" + ) + case .notDetermined: + return ProbeOutcome( + status: .unknown(reason: "speech recognition TCC not yet requested", severity: .soft), + evidence: "SFSpeechRecognizer status=notDetermined" + ) + @unknown default: + return ProbeOutcome( + status: .unknown(reason: "unknown SFSpeechRecognizer status \(status.rawValue)", severity: .soft), + evidence: "SFSpeechRecognizer.authorizationStatus returned unrecognized case" + ) + } + } +} + +// MARK: - TTS (Issue #33) + +/// Cheap watchdog for the TTS engine: verifies the adapter is wired +/// (engine non-nil). The prior `VoiceBootHealthProbe` reported `ok` +/// based only on `audioGraphOwner` + `micStatus`, so a `nil`-engine +/// adapter (the pre-Track-B-3 production state) never surfaced. +/// +/// The probe doesn't actually synthesize at boot — the audit suggests +/// "synthesize empty string" as one option but that would emit +/// audible events into a not-yet-ready audio chain. Cheaper, safer: +/// check whether the adapter has an engine attached. The AppDelegate +/// closure returns true when the TTS adapter was constructed with a +/// real `TTSEngineActor` and false when the dormant path is wired. +public struct TTSBootHealthProbe: BootHealthProbe { + public let name = "tts" + private let engineAlive: @Sendable () async -> Bool + + public init(engineAlive: @escaping @Sendable () async -> Bool) { + self.engineAlive = engineAlive + } + + public func probe() async -> ProbeOutcome { + if await engineAlive() { + return ProbeOutcome( + status: .ok, + evidence: "TTSEngineActor wired" + ) + } + return ProbeOutcome( + status: .degraded(reason: "TTS engine not wired (adapter dormant)", severity: .loud), + evidence: "VoiceTTSAdapter.engine=nil — synth calls no-op" + ) + } +} + // MARK: - Vision /// Camera TCC status + camera device enumeration. No camera frame is diff --git a/App/Tests/AppTests/VoiceWatchdogProbesTests.swift b/App/Tests/AppTests/VoiceWatchdogProbesTests.swift new file mode 100644 index 0000000..8409621 --- /dev/null +++ b/App/Tests/AppTests/VoiceWatchdogProbesTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Jarvis +@testable import Voice + +/// Coverage for the three voice watchdog probes added in +/// audit-2026-05-12 P1-2 / Issue #33. Each probe distinguishes "voice +/// plumbed" from "voice will actually work" — the prior +/// `VoiceBootHealthProbe` reported `ok` even after wake-word died +/// (P0-1) or TTS was never wired. +final class VoiceWatchdogProbesTests: XCTestCase { + + // MARK: - WakeWordBootHealthProbe + + /// W1: nil isArmed (DAG never installed) → `.unknown` soft. + func test_W1_wakeWord_nilDAG_isUnknownSoft() async { + let probe = WakeWordBootHealthProbe(isArmed: { nil }) + let outcome = await probe.probe() + switch outcome.status { + case .unknown(_, let severity): + XCTAssertEqual(severity, .soft, "DAG-never-installed must be soft severity") + default: + XCTFail("expected .unknown for nil DAG, got \(outcome.status)") + } + } + + /// W2: armed feed → `.ok`. + func test_W2_wakeWord_armed_isOk() async { + let probe = WakeWordBootHealthProbe(isArmed: { true }) + let outcome = await probe.probe() + switch outcome.status { + case .ok: + break + default: + XCTFail("expected .ok for armed feed, got \(outcome.status)") + } + } + + /// W3 (Issue #33 regression): DAG installed but feed not armed → + /// `.failed` loud. This is the canonical post-P0-1 silent-death + /// scenario the watchdog must catch. + func test_W3_wakeWord_notArmed_isFailedLoud() async { + let probe = WakeWordBootHealthProbe(isArmed: { false }) + let outcome = await probe.probe() + switch outcome.status { + case .failed(_, let severity): + XCTAssertEqual(severity, .loud, "DAG-dead must be loud severity") + default: + XCTFail("expected .failed for dead DAG, got \(outcome.status). Evidence: \(outcome.evidence)") + } + } + + // MARK: - TTSBootHealthProbe + + /// T1: engine wired → `.ok`. + func test_T1_tts_engineAlive_isOk() async { + let probe = TTSBootHealthProbe(engineAlive: { true }) + let outcome = await probe.probe() + switch outcome.status { + case .ok: + break + default: + XCTFail("expected .ok for live engine, got \(outcome.status)") + } + } + + /// T2 (Issue #33 regression): engine nil (dormant adapter) → + /// `.degraded` loud. Pre-Track-B-3, `VoiceTTSAdapter(engine: nil)` + /// was the production state and synth calls no-op'd silently. The + /// watchdog must surface that today. + func test_T2_tts_engineNil_isDegradedLoud() async { + let probe = TTSBootHealthProbe(engineAlive: { false }) + let outcome = await probe.probe() + switch outcome.status { + case .degraded(_, let severity): + XCTAssertEqual(severity, .loud, "dormant TTS must be loud severity") + default: + XCTFail("expected .degraded for nil engine, got \(outcome.status)") + } + } + + // MARK: - STTBootHealthProbe + + /// S1: STT probe runs and produces some outcome. The exact status + /// depends on the host's `SFSpeechRecognizer` authorization state; + /// we just assert the probe doesn't crash and produces evidence. + func test_S1_stt_probeRunsWithoutCrash() async { + let probe = STTBootHealthProbe() + let outcome = await probe.probe() + XCTAssertFalse(outcome.evidence.isEmpty, "STT probe must produce evidence") + } +} diff --git a/App/Voice/VoiceTTSAdapter.swift b/App/Voice/VoiceTTSAdapter.swift index e3d7744..14fb137 100644 --- a/App/Voice/VoiceTTSAdapter.swift +++ b/App/Voice/VoiceTTSAdapter.swift @@ -41,6 +41,11 @@ actor VoiceTTSAdapter: VoiceTTSInterface { } } + /// Whether a real `TTSEngineActor` is wired (vs. the dormant + /// `engine: nil` path). Used by `TTSBootHealthProbe` + /// (audit-2026-05-12 P1-2 / Issue #33). + var hasEngine: Bool { engine != nil } + func synthesize(_ text: String) async { guard let engine else { return } // dormant — no engine wired yet let tier = await tierResolver() diff --git a/packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift b/packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift index e06a2ea..a5f4e4e 100644 --- a/packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift +++ b/packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift @@ -161,6 +161,18 @@ public actor WakeWordDAG { logger.info("WakeWordDAG: feed stopped (stream preserved for rebuild)") } + /// Whether a feed Task is currently armed against a ring. + /// + /// Used by `VoiceBootHealthProbe` (audit-2026-05-12 P1-2 / Issue #33) + /// as a cheap watchdog — the prior probe only checked + /// `currentVariant != nil` and reported `ok` even after `cancel()` + /// had finished the stream. `true` here means a feed Task exists and + /// hasn't been cancelled. + public var isFeedArmed: Bool { + guard let task = feedTask else { return false } + return !task.isCancelled + } + // MARK: - Private state private let session: OpenWakeWordSession -- 2.54.0 From 4851fe0e0cfc30c5978bbe162aae0ec359291f05 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 16:57:53 -0500 Subject: [PATCH 7/8] =?UTF-8?q?fix(voice):=20reset=20SileroVAD=20hidden=20?= =?UTF-8?q?state=20per=20STT=20session=20=E2=80=94=20closes=20#34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VoiceController.vadFactory` is the closure `{ sileroVAD }` in production — it caches a single `SileroVAD` instance for the process lifetime. `startSTTSession` invoked the factory but never called `reset()`, so LSTM hidden state (and the `wasSpeech` flag) leaked across sessions. The audible failure mode: session N ends in `.speech` state → `wasSpeech == true`. Session N+1's first sub-threshold window emits `.speechEnd` (carrying over), then `.silence`. A soft-spoken first syllable on session N+1 is misclassified — the opening word is dropped from STT input. Fix is one line: `vad.reset()` at the top of `startSTTSession` after `vadFactory()`. `SileroVAD.reset()` already does the right thing (zeroes LSTM `h, c` + `wasSpeech`), it just wasn't being called. Adds VAD-6 regression test: reuses a single `SileroVAD` across two `VoiceController` sessions (production-shape factory pattern). Session 1 runs three speech chunks, then PTT-up. Session 2 feeds a single silence chunk and asserts no submit lands. Pre-fix, session 1's leaked `wasSpeech == true` would have caused session 2's first silence chunk to emit `.speechEnd`, arming a stale finalize that submits the empty STT result. Post-fix, `reset()` clears the state and the session stays in pre-speech with no finalize armed. Co-Authored-By: Claude Opus 4.7 --- .../Voice/Sources/Voice/VoiceController.swift | 8 ++ .../VoiceTests/VADGatedSessionTests.swift | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/packages/Voice/Sources/Voice/VoiceController.swift b/packages/Voice/Sources/Voice/VoiceController.swift index f5ee80e..eb17476 100644 --- a/packages/Voice/Sources/Voice/VoiceController.swift +++ b/packages/Voice/Sources/Voice/VoiceController.swift @@ -424,6 +424,14 @@ public actor VoiceController { } let vad = vadFactory() + // Reset Silero LSTM hidden state between sessions + // (audit-2026-05-12 P1-3 / Issue #34). The vadFactory closure + // typically caches a single `SileroVAD` instance per process — + // without `reset()`, hidden state from session N's `.speechEnd` + // (encoding "we just exited speech") biases session N+1's + // first 1–3 windows toward `.silence`, dropping soft-spoken + // opening syllables from STT input. + vad.reset() let hangover = Self.vadHangoverChunks vadInterceptorTask = Task { [weak self] in await self?.runVADInterceptor( diff --git a/packages/Voice/Tests/VoiceTests/VADGatedSessionTests.swift b/packages/Voice/Tests/VoiceTests/VADGatedSessionTests.swift index 7b9e000..2b46b8e 100644 --- a/packages/Voice/Tests/VoiceTests/VADGatedSessionTests.swift +++ b/packages/Voice/Tests/VoiceTests/VADGatedSessionTests.swift @@ -236,4 +236,102 @@ final class VADGatedSessionTests: XCTestCase { await controller.shutdown() } + + // MARK: - VAD-6 (Issue #34): startSTTSession resets cached VAD + + /// Regression for audit-2026-05-12 P1-3 / Issue #34: production + /// `vadFactory` closure caches a single `SileroVAD` instance. Without + /// `reset()` on each new session, hidden state (specifically + /// `wasSpeech`) leaks across sessions. End of session N with + /// `.speechEnd` leaves `wasSpeech == false`; the first window of + /// session N+1 that crosses threshold then emits `.speechStart` + /// (correct only if reset happened). But end of session N in the + /// `.speech` state leaves `wasSpeech == true`, so session N+1's + /// first sub-threshold window emits `.speechEnd` and the second + /// emits `.silence` — soft-spoken opening syllables are misclassified. + /// + /// Test strategy: drive a session that ends with `wasSpeech == true` + /// (the controller calls `shutdown` mid-speech, then we restart and + /// PTT-down again). On the second session, the first chunk's prob is + /// SILENCE — pre-fix the VAD would emit `.speechEnd` (carrying over + /// from session 1's `.speech`); post-fix it emits `.silence` (clean + /// state via `reset()`). + /// + /// We observe the effect indirectly via the orchestrator submit + /// behaviour: session 2 fed a long silence run after one short + /// silence chunk should NOT submit (no speech ever started). Pre-fix + /// the `.speechEnd → silence` run would arm a finalize that submits + /// the empty STT result. Post-fix, the session stays in pre-speech + /// state and no submit lands. + func testVAD6_cachedVADResetBetweenSessions() async throws { + // Single SileroVAD instance reused across sessions — mirrors + // AppDelegate's `vadFactory: { sileroVAD }` production wiring. + let mockEngine = MockVADEngine(probabilities: []) + let cachedVAD = SileroVAD(engine: mockEngine) + + let stt = CapturingSTTProvider(finalResult: "should not submit") + let orchestrator = MockOrchestratorForController() + let tts = MockTTSForController() + let banner = MockBannerForController() + let bus = MockBusEmitter() + let (_, hudCont) = AsyncStream.makeStream() + let (wwStream, _) = AsyncStream.makeStream() + + // Session 1: speech run that leaves `wasSpeech == true` at the + // SileroVAD level. We feed 3 speech chunks, then shut down + // mid-speech. + mockEngine.setProbabilities([0.9, 0.9, 0.9]) + let controller1 = VoiceController( + wakeWordStream: wwStream, + vadFactory: { cachedVAD }, + sttFactory: { stt }, + tts: tts, + orchestrator: orchestrator, + bannerCoordinator: banner, + bus: bus, + voiceHudCont: hudCont, + chunkPump: makePump(chunkCount: 3) + ) + await controller1.startForTests() + await controller1.pttDown() + try await Task.sleep(for: .milliseconds(80)) + // PttUp triggers `endSTTSession` — cleanly exits session 1. + await controller1.pttUp() + try await Task.sleep(for: .milliseconds(80)) + await controller1.shutdown() + + // Snapshot any submits from session 1. + let session1Submits = await orchestrator.submitCallTexts.count + + // Session 2: feed pure silence (one chunk). Pre-fix, the cached + // VAD's `wasSpeech == true` would mean the first silence chunk + // emits `.speechEnd`, arming the finalize. Post-fix, `reset()` + // clears `wasSpeech` before the session starts, so the first + // silence chunk emits `.silence` (no finalize armed). + mockEngine.setProbabilities([0.1]) + let controller2 = VoiceController( + wakeWordStream: wwStream, + vadFactory: { cachedVAD }, + sttFactory: { stt }, + tts: tts, + orchestrator: orchestrator, + bannerCoordinator: banner, + bus: bus, + voiceHudCont: hudCont, + chunkPump: makePump(chunkCount: 1) + ) + await controller2.startForTests() + await controller2.pttDown() + try await Task.sleep(for: .milliseconds(120)) + // Don't pttUp — let VAD finalize OR not based on its own decision. + try await Task.sleep(for: .milliseconds(120)) + + let session2Submits = await orchestrator.submitCallTexts.count - session1Submits + XCTAssertEqual( + session2Submits, 0, + "Session 2 must not auto-submit when the only chunk is silence — pre-fix the cached VAD's wasSpeech=true would have emitted .speechEnd and armed a stale finalize." + ) + + await controller2.shutdown() + } } -- 2.54.0 From 317b650a79d31da4875e60639c33d8f77ed97e04 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 12 May 2026 17:14:34 -0500 Subject: [PATCH 8/8] =?UTF-8?q?fix(agent):=20pump=20AgentHudIntent=20from?= =?UTF-8?q?=20orchestrator=20events=20=E2=80=94=20closes=20#36?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AppDelegate` constructed `dormantAgentContinuation` and stored it on self, but no production code ever yielded into it. Combined with `VoiceController.hudIntentFor` mapping `.thinking` / `.speaking` / `.idle` all to `.silent`, the HUD ring only saw `.listening` (wake) and `.reconfiguring` (audio-graph rebuilds). Thinking and speaking looked identical to idle. Wire the canonical fix from the audit (Option 2): pump from the orchestrator events broadcaster into the dormant continuation. Changes: - New `OrchestratorEventBroadcaster.Priority.agentHud`. Protects `.stateChange` / `.turnEnd` / `.error` — these drive ring lifecycle transitions and can't be tail-dropped. All other event classes (tokenDelta / thinkingDelta / toolCardUpdate / usage) are drop-eligible (the HUD doesn't render them). - `installAgent` registers an `agentHudSubscriberTask` that subscribes under `.agentHud` and maps `.stateChange(TurnState)` to `AgentHudIntent`: - `.thinking` → `.thinking` - `.speaking` → `.speaking` - `.idle` → `.idle` - `.turnEnd` / `.error` → `.idle` (terminal) - `.booting`, `.listening`, `.awaitingConfirmation`, `.reconfiguring` → no-op (HUD owns these from other sources) - Task is cancelled in `applicationWillTerminate` alongside the other subscriber tasks. Extends the existing `testIsProtectedForPriorityMatrix` test in `OrchestratorEventBroadcasterTests` to cover the new `.agentHud` priority's protection rules. M-6 / M-3 will replace this with a richer orchestrator state machine (distinct `.speaking` emission tied to TTS playback), but the v0.1 patch lights up the ring TODAY. Co-Authored-By: Claude Opus 4.7 --- App/AppDelegate.swift | 62 ++++++++++++++++++- .../OrchestratorEventBroadcaster.swift | 20 ++++++ .../OrchestratorEventBroadcasterTests.swift | 14 +++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift index 440d99e..a920ad8 100644 --- a/App/AppDelegate.swift +++ b/App/AppDelegate.swift @@ -597,6 +597,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Phase E hoist.) private var outboundBatcher: OutboundBatcher? + /// Drains the broadcaster's `.agentHud` priority subscription and + /// maps `.stateChange` / `.turnEnd` to `AgentHudIntent` values + /// yielded into the dormant continuation. Pre-fix, the + /// `dormantAgentContinuation` was constructed but never yielded — + /// the HUD ring stayed `.silent` during thinking and speaking. + /// (audit-2026-05-12 P1-5 / Issue #36.) + private var agentHudSubscriberTask: Task? + /// Filename of the HTML entry the webview loads. Production value is /// `"index"` (the R3F bundle); `installBus` assigns it on construction. /// Test seam — XCTest asserts the post-install value to confirm the @@ -1256,6 +1264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { frameAttachReleaseTask?.cancel() voiceEventTranslatorTask?.cancel() busSubscriberTask?.cancel() + agentHudSubscriberTask?.cancel() if let broadcaster = eventBroadcaster { Task { await broadcaster.stop() } } @@ -2242,8 +2251,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + // 5g. Agent HUD intent pump (audit-2026-05-12 P1-5 / Issue #36). + // Pre-fix the `dormantAgentContinuation` was constructed but + // never yielded into — `grep -rn dormantAgentContinuation App/` + // returned only the declaration + assignment. Combined with + // `VoiceController.hudIntentFor` mapping `.thinking` / + // `.speaking` to `.silent`, the HUD ring saw `.listening` on + // wake, `.reconfiguring` during rebuilds, and `.silent` + // everywhere else. Thinking and speaking looked identical to + // idle. + // + // This subscriber maps the orchestrator's `.stateChange` / + // `.turnEnd` to `AgentHudIntent` values yielded into the + // continuation so the HUD ring reflects agent activity. The + // `.agentHud` priority protects `.stateChange` / `.turnEnd` / + // `.error` — the ring's lifecycle transitions can't be + // tail-dropped under overload. + let agentHudSub = await broadcaster.subscribe(priority: .agentHud, capacity: 32) + self.agentHudSubscriberTask = Task { [weak self] in + guard let self else { return } + // Capture the continuation on entry; the `dormantAgentContinuation` + // is `@MainActor`-isolated so reads need an actor hop. + let cont = await MainActor.run { self.dormantAgentContinuation } + guard let cont else { return } + for await event in agentHudSub.stream { + if Task.isCancelled { break } + switch event { + case .stateChange(let state): + // Translate TurnState → AgentHudIntent. Tier-1 TTS + // (AVSpeech) emits the user-audible portion of an + // assistant reply; today we don't have a distinct + // `.speaking` state on the orchestrator (it stays + // `.thinking` until terminal `.idle`), so map + // everything non-idle/non-reconfiguring to `.thinking`. + // M-6 wires a richer state machine. + switch state { + case .idle: cont.yield(.idle) + case .thinking: cont.yield(.thinking) + case .speaking: cont.yield(.speaking) + case .booting, .listening, + .awaitingConfirmation, + .reconfiguring: + break // HUD owns these from other sources + } + case .turnEnd, .error: + cont.yield(.idle) + default: + break + } + } + } + systemLogger?.info( - "installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice + bus subscribers active)" + "installAgent: AgentOrchestrator + broadcaster + transcript store wired (memory + transcript + devOverlay + frameAttach + voice + bus + agentHud subscribers active)" ) } diff --git a/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift b/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift index e8ce402..5bb557f 100644 --- a/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift +++ b/packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift @@ -54,6 +54,18 @@ public actor OrchestratorEventBroadcaster { /// emitted to webview) by giving AppDelegate a canonical /// priority to subscribe under for bus-forwarding. case bus + /// HUD-state subscriber — drains `.stateChange` / `.turnEnd` to + /// the dormant `AgentHudIntent` continuation so the HUD ring + /// reflects agent activity (thinking / speaking / idle). + /// `.stateChange` / `.turnEnd` are protected; all other events + /// (tokenDelta / thinkingDelta / usage / toolCardUpdate / error) + /// are drop-eligible — the HUD doesn't render tokens itself. + /// + /// (audit-2026-05-12 P1-5 / Issue #36: prior to this priority, + /// the `dormantAgentContinuation` was constructed but never + /// yielded into — the HUD ring stayed `.silent` during + /// thinking and speaking.) + case agentHud } private let upstream: BoundedAsyncChannel @@ -176,6 +188,14 @@ public actor OrchestratorEventBroadcaster { case .turnEnd, .error: return true default: return false } + case .agentHud: + // HUD-state subscriber — protect lifecycle events; tokens, + // thinking-text and tool cards aren't needed for ring + // transitions and can drop under overload. + switch event { + case .stateChange, .turnEnd, .error: return true + default: return false + } } } } diff --git a/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift b/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift index 2221e11..fe80a43 100644 --- a/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift +++ b/packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift @@ -298,6 +298,20 @@ final class OrchestratorEventBroadcasterTests: XCTestCase { ".bus priority protection wrong for \(label)" ) } + + // agentHud (audit-2026-05-12 P1-5 / Issue #36): protects + // .stateChange, .turnEnd, .error — these drive ring lifecycle + // transitions and must not be tail-dropped. All other classes + // (tokenDelta / thinkingDelta / toolCardUpdate / usage) are + // drop-eligible — the HUD doesn't render them. + for (label, event) in allEvents { + let isProtected = await broadcaster.isProtectedForPriority(event, priority: .agentHud) + let expected = (label == "stateChange" || label == "turnEnd" || label == "error") + XCTAssertEqual( + isProtected, expected, + ".agentHud priority protection wrong for \(label)" + ) + } } // MARK: - Helpers -- 2.54.0