From fe7b35710ffbbb4df6be42e2d5af3c4389304778 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Tue, 21 Jul 2026 21:36:55 -0400 Subject: [PATCH 01/11] feat(desktop): deliver background-agent completions into live voice sessions A voice-spawned background agent's completion was invisible to the realtime voice model: the kernel journaled the run and the floating pill turned green, but no observer converted "done" into conversation input for the live provider session, so the model could not mention the result or answer "what did it find". The pull API built for exactly this (peekCompletedAgentDelta, with per-surface seen-id + high-water checkpoints) had zero callers. AgentCompletionVoiceDelivery wires that orphaned pipeline to the realtime hub: - Watches AgentRuntimeStatusStore projections for transitions into a terminal state on background surfaces only (floating_bar/service/workstream), so ordinary chat/voice answers never trigger kernel delta reads. - Reads the canonical completion delta from the kernel under the realtime_voice checkpoint namespace and injects the existing delta prompt (already fenced as untrusted, already says "do not read raw ids aloud") into the live provider session as silent context via the same buffered text-input path PTT uses. No synthetic response is requested, so the voice turn coordinator's output-lease authority and the one-start/one-terminal turn analytics contract are untouched; the model mentions the completion at the next natural turn boundary. - Acknowledges the checkpoint only after the session confirms the send, so an undelivered completion is retried (next transition or next session connect) instead of lost; hubDidConnect drains completions that finished while no session was live. Verification: - xcrun swift build -c debug --package-path Desktop: clean - New behavioral suite AgentCompletionVoiceDeliveryTests (9 tests) passes: delivery+ack ordering, checkpoint-unadvanced on failed inject, no-op without a live session, primary-surface transitions never trigger, repeated terminal projections fire once, cleared-and-recreated surface is a fresh transition, connect drains pending, in-flight coalescing, empty delta acks nothing. - ./scripts/agent-logic-harness.sh: all four gates green (focused Swift lifecycle/state, agent runtime, pi-mono-extension, self-check). - python3 scripts/check_desktop_test_quality.py: at/below baseline. - Not exercised live: a real voice session observing a completion on a named bundle (requires provider audio session); noted in PR for follow-up. --- .../desktop-swift-floatingcontrolbar.json | 4 +- .../desktop-swift-root.json | 4 +- .../Chat/AgentCompletionVoiceDelivery.swift | 146 ++++++++++++++ ...HubController+AgentCompletionContext.swift | 17 ++ ...ealtimeHubController+SessionDelegate.swift | 1 + .../RealtimeHubSession.swift | 7 + desktop/macos/Desktop/Sources/OmiApp.swift | 3 + .../AgentCompletionVoiceDeliveryTests.swift | 189 ++++++++++++++++++ ...260721-voice-agent-completion-context.json | 3 + .../e2e/flows/floating-bar-functional.yaml | 2 + 10 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift create mode 100644 desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+AgentCompletionContext.swift create mode 100644 desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260721-voice-agent-completion-context.json diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json index 42034731498..6b184c94bf4 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json @@ -4,14 +4,14 @@ "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2853, "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4842, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1621 + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1628 }, "raise_justifications": { "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": "Agent completion presentation now follows the canonical terminal lifecycle used by PTT and chat.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "Onboarding bar glow, the reach-error card (Retry/Skip), and the notch hover/voice surface-state boundary render on the shared bar surface.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Reach-error state lives with the owning manager; savePreChatCenterIfNeeded snaps to the full stored restore frame so the pill no longer drifts per re-open cycle.", "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior." + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "sendBackgroundAgentContext must live beside the file-private sendTextInput buffering path it wraps; the AgentCompletionVoiceDelivery logic itself is a separate file." }, "threshold": 1500 } diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json index ab6b8525f8f..621aa69dfb9 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json @@ -4,13 +4,13 @@ "desktop/macos/Desktop/Sources/CloudConnectorFormAutomation.swift": 1678, "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4238, "desktop/macos/Desktop/Sources/MemoryExportService.swift": 1578, - "desktop/macos/Desktop/Sources/OmiApp.swift": 1646 + "desktop/macos/Desktop/Sources/OmiApp.swift": 1649 }, "raise_justifications": { "desktop/macos/Desktop/Sources/AuthService.swift": "Apple first-auth name capture signals observers (authNameDidUpdate) and persists the name to Firebase so it survives reinstalls.", "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Reach-error card gains a debug bridge action so the actionable failure surface is verifiable in-process.", "desktop/macos/Desktop/Sources/MemoryExportService.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", - "desktop/macos/Desktop/Sources/OmiApp.swift": "openMainAppChat() gives the floating bar Continue-in-Omi affordances a chat-landing entry next to openMainAppWindow()." + "desktop/macos/Desktop/Sources/OmiApp.swift": "openMainAppChat() adds a chat-landing entry beside openMainAppWindow(), and one startup call wires AgentCompletionVoiceDelivery into launch beside the other singleton starts; the service is a separate file." }, "threshold": 1500 } diff --git a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift new file mode 100644 index 00000000000..2c16b726ade --- /dev/null +++ b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift @@ -0,0 +1,146 @@ +import Combine +import Foundation + +/// Bridges completed background-agent runs into the live realtime voice +/// conversation. +/// +/// The kernel journals a completed run and the floating pill shows it, but a +/// live voice session has no eyes: nothing tells the realtime model that the +/// agent it spawned has finished. This service watches run projections for +/// transitions into a terminal state on background surfaces, reads the +/// canonical completion delta from the kernel (`peekCompletedAgentDelta`, +/// exactly-once via the per-surface checkpoint), and injects the delta prompt +/// into the live provider session as untrusted context. No synthetic response +/// is requested — the voice turn coordinator's output authority is untouched; +/// the model mentions the completion at the next natural turn boundary and can +/// answer follow-ups. The checkpoint advances only after the session confirms +/// the send, so an undelivered completion is retried instead of lost. +@MainActor +final class AgentCompletionVoiceDelivery { + static let shared = AgentCompletionVoiceDelivery() + + /// Background surfaces whose terminal transitions can carry a user-facing + /// completion. Primary conversational surfaces (main_chat, realtime_voice, + /// task_chat, …) reach a terminal state on every ordinary answer and must + /// not trigger kernel delta reads. + static let triggerSurfaceKinds: Set = ["floating_bar", "service", "workstream"] + + struct Delta { + let ids: [String] + let prompt: String + let completedAtHighWaterMs: Int? + } + + private let isVoiceSessionLive: @MainActor () -> Bool + private let peekDelta: @MainActor () async -> Delta? + private let injectContext: @MainActor (String) async -> Bool + private let acknowledge: @MainActor (Delta) -> Void + private let scheduleWork: @MainActor (@escaping @MainActor () async -> Void) -> Void + + private var cancellable: AnyCancellable? + private var lastStatusBySurface: [String: AgentRunProjectionStatus] = [:] + private var deliveryInFlight = false + private var deliveryQueued = false + + init( + isVoiceSessionLive: (@MainActor () -> Bool)? = nil, + peekDelta: (@MainActor () async -> Delta?)? = nil, + injectContext: (@MainActor (String) async -> Bool)? = nil, + acknowledge: (@MainActor (Delta) -> Void)? = nil, + scheduleWork: (@MainActor (@escaping @MainActor () async -> Void) -> Void)? = nil + ) { + self.isVoiceSessionLive = + isVoiceSessionLive ?? { RealtimeHubController.shared.hasLiveVoiceSession } + self.peekDelta = + peekDelta + ?? { + guard + let delta = await DesktopCoordinatorService.shared.peekCompletedAgentDelta( + surface: .realtimeVoice()) + else { return nil } + return Delta( + ids: delta.ids, + prompt: delta.prompt, + completedAtHighWaterMs: delta.completedAtHighWaterMs) + } + self.injectContext = + injectContext + ?? { text in + await RealtimeHubController.shared.injectBackgroundAgentCompletionContext(text) + } + self.acknowledge = + acknowledge + ?? { delta in + DesktopCoordinatorService.shared.acknowledgeCompletedAgentDelta( + surface: .realtimeVoice(), + ids: delta.ids, + completedAtHighWaterMs: delta.completedAtHighWaterMs) + } + self.scheduleWork = scheduleWork ?? { work in Task { await work() } } + } + + /// Idempotent; called once from app launch. + func start() { + guard cancellable == nil else { return } + cancellable = AgentRuntimeStatusStore.shared.$projectionsBySurface + .sink { [weak self] projections in + self?.observe(projections) + } + } + + /// A newly connected voice session drains completions that finished while no + /// session was live (their checkpoint was deliberately left unadvanced). + func voiceSessionDidConnect() { + scheduleDelivery() + } + + func observe(_ projections: [String: AgentRunProjection]) { + var fired = false + for (key, projection) in projections { + let previous = lastStatusBySurface[key] + lastStatusBySurface[key] = projection.status + guard + projection.status.isTerminal, + previous != projection.status, + previous?.isTerminal != true, + Self.triggerSurfaceKinds.contains(projection.surface.surfaceKind) + else { continue } + fired = true + } + // Prune surfaces that disappeared so a cleared-and-recreated surface is a + // fresh transition rather than a suppressed repeat. + lastStatusBySurface = lastStatusBySurface.filter { projections[$0.key] != nil } + if fired { + scheduleDelivery() + } + } + + private func scheduleDelivery() { + if deliveryInFlight { + deliveryQueued = true + return + } + deliveryInFlight = true + scheduleWork { [weak self] in + await self?.runDelivery() + } + } + + private func runDelivery() async { + defer { + deliveryInFlight = false + if deliveryQueued { + deliveryQueued = false + scheduleDelivery() + } + } + guard isVoiceSessionLive() else { return } + guard let delta = await peekDelta() else { return } + guard await injectContext(delta.prompt) else { + log("AgentCompletionVoiceDelivery: completion context not delivered; checkpoint unadvanced") + return + } + acknowledge(delta) + log("AgentCompletionVoiceDelivery: delivered \(delta.ids.count) completion(s) to live voice session") + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+AgentCompletionContext.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+AgentCompletionContext.swift new file mode 100644 index 00000000000..26cefdc1750 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+AgentCompletionContext.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Seam for `AgentCompletionVoiceDelivery`: lets completed background-agent +/// results reach the live voice conversation as silent context, without +/// touching the voice turn coordinator's output authority. +extension RealtimeHubController { + /// True when a physical provider session exists (including warm-idle). + var hasLiveVoiceSession: Bool { session != nil } + + /// Adds completed background-agent context to the live conversation without + /// requesting a response. Returns false when no session is live so the + /// caller leaves the completion checkpoint unadvanced and retries later. + func injectBackgroundAgentCompletionContext(_ text: String) async -> Bool { + guard let session else { return false } + return await session.sendBackgroundAgentContext(text) + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift index 1f19bddfdf9..0cc4d9d62c2 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift @@ -608,6 +608,7 @@ extension RealtimeHubController { guard isCurrentSession(source) else { return } lastWarmAt = Date() hubConnected = true // authenticated + ready — PTT may now route turns to the hub + AgentCompletionVoiceDelivery.shared.voiceSessionDidConnect() let replayedReconnectTurn = reconnectAudioBuffer != nil let replayedReplacementTurn = replacementAudioBuffer != nil if replayedReplacementTurn { diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift index 50ad0945a09..31eefd6aac3 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift @@ -500,6 +500,13 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { await sendTextInput(text, logLabel: "test text input") } + /// Silently appends completed background-agent context to the conversation. + /// No response is requested — the model uses it on its next turn. Buffered + /// like other text input while the socket or Gemini activity window is closed. + func sendBackgroundAgentContext(_ text: String) async -> Bool { + await sendTextInput(text, logLabel: "background agent completion context") + } + /// A provider can complete a tool-only response after accepting the final tool /// result without emitting a user-facing reply. Continue the same physical turn /// once, never as a synthetic user request. The continuation is bounded here so diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index c70c1d63374..f18c0b11b86 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -484,6 +484,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, @unchecked S // Start resource monitoring (memory, CPU, disk) ResourceMonitor.shared.start() + // Route completed background-agent results into live voice sessions. + AgentCompletionVoiceDelivery.shared.start() + scheduleAppLifecycleMaintenance() // Identify user if already signed in diff --git a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift new file mode 100644 index 00000000000..9fb76069fce --- /dev/null +++ b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift @@ -0,0 +1,189 @@ +import XCTest + +@testable import Omi_Computer + +/// Behavioral coverage for the voice completion-delivery dispatcher: completed +/// background-agent runs must reach a live voice session exactly once, and the +/// kernel checkpoint must advance only after the session confirmed the send. +@MainActor +final class AgentCompletionVoiceDeliveryTests: XCTestCase { + + @MainActor + private final class Harness { + var voiceLive = true + var delta: AgentCompletionVoiceDelivery.Delta? = AgentCompletionVoiceDelivery.Delta( + ids: ["run-1"], prompt: "agent finished", completedAtHighWaterMs: 42) + var injectResult = true + + private(set) var peekCount = 0 + private(set) var injectedPrompts: [String] = [] + private(set) var acknowledged: [[String]] = [] + private(set) var scheduled: [@MainActor () async -> Void] = [] + + lazy var sut = AgentCompletionVoiceDelivery( + isVoiceSessionLive: { [unowned self] in self.voiceLive }, + peekDelta: { [unowned self] in + self.peekCount += 1 + return self.delta + }, + injectContext: { [unowned self] prompt in + self.injectedPrompts.append(prompt) + return self.injectResult + }, + acknowledge: { [unowned self] delta in + self.acknowledged.append(delta.ids) + }, + scheduleWork: { [unowned self] work in + self.scheduled.append(work) + } + ) + + /// Runs every scheduled delivery, including trailing re-runs queued while + /// a delivery was in flight. + func drainScheduledWork() async { + while !scheduled.isEmpty { + let work = scheduled.removeFirst() + await work() + } + } + } + + private func projection( + surface: AgentSurfaceReference, + status: AgentRunProjectionStatus + ) -> AgentRunProjection { + AgentRunProjection( + surface: surface, + sessionId: "session-1", + runId: "run-1", + attemptId: nil, + adapterSessionId: nil, + status: status, + statusText: nil, + errorMessage: nil, + failure: nil, + updatedAt: Date(timeIntervalSince1970: 1_000), + completedAt: nil, + costUsd: nil, + inputTokens: nil, + outputTokens: nil + ) + } + + func testBackgroundTerminalTransitionDeliversAndAcknowledges() async { + let harness = Harness() + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + harness.sut.observe([surface.key: projection(surface: surface, status: .running)]) + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 1) + XCTAssertEqual(harness.injectedPrompts, ["agent finished"]) + XCTAssertEqual(harness.acknowledged, [["run-1"]]) + } + + func testInjectFailureLeavesCheckpointUnadvanced() async { + let harness = Harness() + harness.injectResult = false + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.injectedPrompts.count, 1) + XCTAssertTrue(harness.acknowledged.isEmpty, "checkpoint must not advance on failed delivery") + } + + func testNoLiveVoiceSessionDoesNotPeekOrAcknowledge() async { + let harness = Harness() + harness.voiceLive = false + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + harness.sut.observe([surface.key: projection(surface: surface, status: .failed)]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 0) + XCTAssertTrue(harness.injectedPrompts.isEmpty) + XCTAssertTrue(harness.acknowledged.isEmpty) + } + + func testPrimarySurfaceTerminalsDoNotTrigger() async { + let harness = Harness() + let mainChat = AgentSurfaceReference.mainChat(chatId: "chat-1") + let voice = AgentSurfaceReference.realtimeVoice() + + harness.sut.observe([ + mainChat.key: projection(surface: mainChat, status: .succeeded), + voice.key: projection(surface: voice, status: .succeeded), + ]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 0, "ordinary chat/voice answers must not trigger delta reads") + } + + func testRepeatedTerminalProjectionFiresOnce() async { + let harness = Harness() + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + let terminal = projection(surface: surface, status: .succeeded) + + harness.sut.observe([surface.key: terminal]) + await harness.drainScheduledWork() + harness.sut.observe([surface.key: terminal]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 1, "an unchanged terminal projection is not a new completion") + } + + func testSurfaceRemovalThenRecreationIsAFreshTransition() async { + let harness = Harness() + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + harness.sut.observe([:]) + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 2, "a cleared-and-recreated surface is a fresh completion") + } + + func testVoiceSessionConnectDrainsPendingCompletions() async { + let harness = Harness() + + harness.sut.voiceSessionDidConnect() + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 1) + XCTAssertEqual(harness.acknowledged, [["run-1"]]) + } + + func testTransitionsWhileDeliveryInFlightCoalesceIntoOneTrailingRun() async { + let harness = Harness() + let first = AgentSurfaceReference.floatingBarRun(runId: "run-1") + let second = AgentSurfaceReference.floatingBarRun(runId: "run-2") + + harness.sut.observe([first.key: projection(surface: first, status: .succeeded)]) + // Delivery is scheduled but has not run yet — two more transitions arrive. + harness.sut.observe([ + first.key: projection(surface: first, status: .succeeded), + second.key: projection(surface: second, status: .succeeded), + ]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 2, "in-flight transitions coalesce into one trailing delivery") + } + + func testEmptyDeltaAcknowledgesNothing() async { + let harness = Harness() + harness.delta = nil + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 1) + XCTAssertTrue(harness.injectedPrompts.isEmpty) + XCTAssertTrue(harness.acknowledged.isEmpty) + } +} diff --git a/desktop/macos/changelog/unreleased/20260721-voice-agent-completion-context.json b/desktop/macos/changelog/unreleased/20260721-voice-agent-completion-context.json new file mode 100644 index 00000000000..74bdb775486 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260721-voice-agent-completion-context.json @@ -0,0 +1,3 @@ +{ + "change": "Voice conversations now learn when a background agent finishes, so Omi can mention the result and answer follow-up questions about it" +} diff --git a/desktop/macos/e2e/flows/floating-bar-functional.yaml b/desktop/macos/e2e/flows/floating-bar-functional.yaml index 65f0f089fc7..1797b832eae 100644 --- a/desktop/macos/e2e/flows/floating-bar-functional.yaml +++ b/desktop/macos/e2e/flows/floating-bar-functional.yaml @@ -4,6 +4,7 @@ tier: 2 description: Ask Omi open + stubbed typed turn on the floating bar (distinct from perf benchmarks) app: non-prod covers: + - desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift - desktop/macos/Desktop/Sources/Chat/ChatDraftStore.swift - desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift - desktop/macos/Desktop/Sources/MainWindow/ClickThroughView.swift @@ -19,6 +20,7 @@ covers: - desktop/macos/Desktop/Sources/FloatingControlBar/PTTContextVocabularyProvider.swift - desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift - desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+AgentCompletionContext.swift - desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift - desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionLifecycle.swift - desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+Tools.swift From 6202601999bda425d78869f5c18ec67edd890037 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Tue, 21 Jul 2026 23:07:42 -0400 Subject: [PATCH 02/11] test(desktop): gate DEBUG-seam AgentSync recovery tests for release compiles AgentSyncRecoveryTests drives AgentSyncService's startForTesting / syncOnceForTesting seam, which only exists under #if DEBUG (AgentSyncService.swift:194). The debug test build compiles, but the release-mode notification regression step (run-swift-ci.sh --release-notification-regression) compiles the test bundle in release and fails on every PR since the suite landed. Wrap the suite in #if DEBUG with the established omi-release-compile marker, matching APIClientAuthRetryTests and the other DEBUG-seam suites. Verification: xcrun swift build --build-tests (debug) clean locally; full suite + release regression step run on the qualification machine (evidence in PR thread). --- .../Tests/AgentSyncBatchQueryTests.swift | 505 +++++++++--------- 1 file changed, 256 insertions(+), 249 deletions(-) diff --git a/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift b/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift index 895c02cf388..10acaee6395 100644 --- a/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift +++ b/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift @@ -348,267 +348,274 @@ final class AgentSyncBatchQueryTests: XCTestCase { } } -/// These tests drive `syncTick` through the same table reads and HTTP paths as -/// the loop. The DEBUG-only clock/hook seam avoids a scheduler or bridge fault -/// protocol while preserving production ownership and recovery behavior. -final class AgentSyncRecoveryTests: XCTestCase { - private var storageFixture: RewindStorageTestIsolation.Fixture? - private var authSnapshot: RewindStorageTestIsolation.AuthSnapshot? - - override func setUp() async throws { - try await super.setUp() - let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "agent-sync-recovery") - storageFixture = fixture - authSnapshot = await MainActor.run { RewindStorageTestIsolation.captureAuthSnapshot() } - await MainActor.run { RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) } - try await insertSyncRows() - } - - override func tearDown() async throws { - if let authSnapshot { - await MainActor.run { RewindStorageTestIsolation.restoreAuthSnapshot(authSnapshot) } +#if DEBUG + // omi-release-compile: this suite drives AgentSyncService's DEBUG-only + // startForTesting/syncOnceForTesting seam; the release-mode notification + // regression step must compile the bundle without it. + + /// These tests drive `syncTick` through the same table reads and HTTP paths as + /// the loop. The DEBUG-only clock/hook seam avoids a scheduler or bridge fault + /// protocol while preserving production ownership and recovery behavior. + final class AgentSyncRecoveryTests: XCTestCase { + private var storageFixture: RewindStorageTestIsolation.Fixture? + private var authSnapshot: RewindStorageTestIsolation.AuthSnapshot? + + override func setUp() async throws { + try await super.setUp() + let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "agent-sync-recovery") + storageFixture = fixture + authSnapshot = await MainActor.run { RewindStorageTestIsolation.captureAuthSnapshot() } + await MainActor.run { RewindStorageTestIsolation.signInForTests(userId: fixture.testUserId) } + try await insertSyncRows() } - await RewindStorageTestIsolation.tearDown(userDir: storageFixture?.userDir) - try await super.tearDown() - } - func testMixedSuccessStillRecoversTheRepeatedRequiredTableFailure() async { - let probe = AgentSyncRecoveryProbe() - let service = makeService(probe: probe) - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + override func tearDown() async throws { + if let authSnapshot { + await MainActor.run { RewindStorageTestIsolation.restoreAuthSnapshot(authSnapshot) } + } + await RewindStorageTestIsolation.tearDown(userDir: storageFixture?.userDir) + try await super.tearDown() + } - await service.syncOnceForTesting() - await service.syncOnceForTesting() - await service.syncOnceForTesting() + func testMixedSuccessStillRecoversTheRepeatedRequiredTableFailure() async { + let probe = AgentSyncRecoveryProbe() + let service = makeService(probe: probe) + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + + await service.syncOnceForTesting() + await service.syncOnceForTesting() + await service.syncOnceForTesting() + + let counts = await probe.counts() + XCTAssertTrue( + counts.syncedTables.contains("action_items"), "The control table must take the real /sync success path") + XCTAssertEqual(counts.syncedTables.filter { $0 == "transcription_sessions" }.count, 3) + XCTAssertEqual(counts.healthChecks, 1, "Three causal failures trigger one fail-closed /health check") + XCTAssertEqual(counts.uploads, 1, "The existing database-upload owner repairs the missing table exactly once") + } - let counts = await probe.counts() - XCTAssertTrue( - counts.syncedTables.contains("action_items"), "The control table must take the real /sync success path") - XCTAssertEqual(counts.syncedTables.filter { $0 == "transcription_sessions" }.count, 3) - XCTAssertEqual(counts.healthChecks, 1, "Three causal failures trigger one fail-closed /health check") - XCTAssertEqual(counts.uploads, 1, "The existing database-upload owner repairs the missing table exactly once") - } + func testTwoMissingRequiredTablesDoNotAlternateAwayTheSelectedRecovery() async { + let probe = AgentSyncRecoveryProbe() + await probe.setMissingTables(["transcription_sessions", "action_items"]) + let service = makeService(probe: probe) + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + + for _ in 0..<3 { await service.syncOnceForTesting() } + + let counts = await probe.counts() + XCTAssertEqual(counts.syncedTables.filter { $0 == "transcription_sessions" }.count, 3) + XCTAssertEqual(counts.syncedTables.filter { $0 == "action_items" }.count, 3) + XCTAssertTrue( + counts.syncedTables.contains("memories"), + "A successful required table must not suppress recovery for the selected missing table" + ) + XCTAssertEqual( + counts.healthChecks, 1, "Alternating missing tables must still reach the causal recovery threshold") + XCTAssertEqual(counts.uploads, 1, "One selected causal table produces one bounded repair") + } - func testTwoMissingRequiredTablesDoNotAlternateAwayTheSelectedRecovery() async { - let probe = AgentSyncRecoveryProbe() - await probe.setMissingTables(["transcription_sessions", "action_items"]) - let service = makeService(probe: probe) - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + func testMatchingTableSuccessClearsRecoveryButUnrelatedSuccessDoesNot() async throws { + let probe = AgentSyncRecoveryProbe() + let service = makeService(probe: probe) + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + + await service.syncOnceForTesting() + await service.syncOnceForTesting() + await probe.setMissingTable(nil) // The previously failing table now succeeds. + await service.syncOnceForTesting() + await probe.setMissingTable("transcription_sessions") + try await touchTranscriptionSession() + await service.syncOnceForTesting() + await service.syncOnceForTesting() + + var counts = await probe.counts() + XCTAssertEqual(counts.uploads, 0, "A matching success clears the earlier table's causal evidence") + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 1, "Only three new failures of the same required table recover it") + } - for _ in 0..<3 { await service.syncOnceForTesting() } + func testMalformedAndNonSuccessHealthNeverUpload() async { + let probe = AgentSyncRecoveryProbe() + await probe.setHealthResponse(.malformed) + let service = makeService(probe: probe) + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + + for _ in 0..<3 { await service.syncOnceForTesting() } + var counts = await probe.counts() + XCTAssertEqual(counts.healthChecks, 1) + XCTAssertEqual(counts.uploads, 0) + + await probe.setHealthResponse(.status(503)) + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 0, "Non-2xx health responses fail closed before upload") + } - let counts = await probe.counts() - XCTAssertEqual(counts.syncedTables.filter { $0 == "transcription_sessions" }.count, 3) - XCTAssertEqual(counts.syncedTables.filter { $0 == "action_items" }.count, 3) - XCTAssertTrue( - counts.syncedTables.contains("memories"), - "A successful required table must not suppress recovery for the selected missing table" - ) - XCTAssertEqual(counts.healthChecks, 1, "Alternating missing tables must still reach the causal recovery threshold") - XCTAssertEqual(counts.uploads, 1, "One selected causal table produces one bounded repair") - } - - func testMatchingTableSuccessClearsRecoveryButUnrelatedSuccessDoesNot() async throws { - let probe = AgentSyncRecoveryProbe() - let service = makeService(probe: probe) - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") - - await service.syncOnceForTesting() - await service.syncOnceForTesting() - await probe.setMissingTable(nil) // The previously failing table now succeeds. - await service.syncOnceForTesting() - await probe.setMissingTable("transcription_sessions") - try await touchTranscriptionSession() - await service.syncOnceForTesting() - await service.syncOnceForTesting() - - var counts = await probe.counts() - XCTAssertEqual(counts.uploads, 0, "A matching success clears the earlier table's causal evidence") - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 1, "Only three new failures of the same required table recover it") - } - - func testMalformedAndNonSuccessHealthNeverUpload() async { - let probe = AgentSyncRecoveryProbe() - await probe.setHealthResponse(.malformed) - let service = makeService(probe: probe) - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") - - for _ in 0..<3 { await service.syncOnceForTesting() } - var counts = await probe.counts() - XCTAssertEqual(counts.healthChecks, 1) - XCTAssertEqual(counts.uploads, 0) - - await probe.setHealthResponse(.status(503)) - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 0, "Non-2xx health responses fail closed before upload") - } - - func testSameOwnerRestartPreservesCooldownAndFailedUploadsStayBounded() async { - let probe = AgentSyncRecoveryProbe() - await probe.setUploadResults([false, false, false]) - let clock = AgentSyncManualClock() - let service = makeService(probe: probe, clock: clock) - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") - - for _ in 0..<3 { await service.syncOnceForTesting() } - var counts = await probe.counts() - XCTAssertEqual(counts.uploads, 1) - - await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 1, "Same-owner restart cannot mint a new cooldown allowance") - - clock.advance(30 * 60 + 1) - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 2) - - clock.advance(30 * 60 + 1) - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 2, "Failed recovery uploads stop at the existing bounded policy") - } - - func testSameOwnerReplacementVMResetsOldRecoveryEvidenceCooldownAndBudget() async { - let probe = AgentSyncRecoveryProbe() - await probe.setUploadResults([false, false, false]) - let clock = AgentSyncManualClock() - let service = makeService(probe: probe, clock: clock) - await service.startForTesting(vmIP: "old-vm", authToken: "test-token") - - for _ in 0..<3 { await service.syncOnceForTesting() } - clock.advance(30 * 60 + 1) - await service.syncOnceForTesting() - var counts = await probe.counts() - XCTAssertEqual(counts.uploads, 2, "The old VM exhausts its bounded retry budget") - - await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") - await service.syncOnceForTesting() - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 2, "Replacement must not upload from stale old-VM evidence") - - await service.syncOnceForTesting() - counts = await probe.counts() - XCTAssertEqual(counts.uploads, 3, "Replacement gets a fresh causal threshold and bounded retry budget") - } - - func testDelayedOldVMSyncResponseCannotCreateReplacementRecoveryEvidence() async { - let gate = AgentSyncReplacementCallbackGate(suspending: .sync) - let service = makeService(gate: gate) - await service.startForTesting(vmIP: "old-vm", authToken: "test-token") - let oldTick = Task { await service.syncOnceForTesting() } - await gate.waitUntilStarted(.sync) - - await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") - await gate.release(.sync) - await oldTick.value - for _ in 0..<3 { await service.syncOnceForTesting() } - - let uploadVMs = await gate.uploadVMs() - XCTAssertEqual(uploadVMs, ["replacement-vm"]) - } - - func testDelayedOldVMHealthResponseCannotSpendReplacementRecoveryBudget() async { - let gate = AgentSyncReplacementCallbackGate(suspending: .health) - let service = makeService(gate: gate) - await service.startForTesting(vmIP: "old-vm", authToken: "test-token") - await service.syncOnceForTesting() - await service.syncOnceForTesting() - let oldTick = Task { await service.syncOnceForTesting() } - await gate.waitUntilStarted(.health) - - await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") - await gate.release(.health) - await oldTick.value - for _ in 0..<3 { await service.syncOnceForTesting() } - - let uploadVMs = await gate.uploadVMs() - XCTAssertEqual(uploadVMs, ["replacement-vm"]) - } - - func testDelayedOldVMUploadResponseCannotClearReplacementRecoveryEvidence() async { - let gate = AgentSyncReplacementCallbackGate(suspending: .upload) - let service = makeService(gate: gate) - await service.startForTesting(vmIP: "old-vm", authToken: "test-token") - await service.syncOnceForTesting() - await service.syncOnceForTesting() - let oldTick = Task { await service.syncOnceForTesting() } - await gate.waitUntilStarted(.upload) - - await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") - await service.syncOnceForTesting() - await service.syncOnceForTesting() - await gate.release(.upload) - await oldTick.value - await service.syncOnceForTesting() - - let uploadVMs = await gate.uploadVMs() - XCTAssertEqual(uploadVMs, ["old-vm", "replacement-vm"]) - } - - private func makeService( - probe: AgentSyncRecoveryProbe, - clock: AgentSyncManualClock = AgentSyncManualClock() - ) -> AgentSyncService { - AgentSyncService( - networkHooks: AgentSyncService.NetworkHooks( - fetchIDToken: { "test-firebase-token" }, - dataForRequest: { request in try await probe.respond(to: request) }, - reuploadDatabase: { _, _ in await probe.reupload() }, - now: { clock.now() }, - tableSyncEnabled: true)) - } + func testSameOwnerRestartPreservesCooldownAndFailedUploadsStayBounded() async { + let probe = AgentSyncRecoveryProbe() + await probe.setUploadResults([false, false, false]) + let clock = AgentSyncManualClock() + let service = makeService(probe: probe, clock: clock) + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + + for _ in 0..<3 { await service.syncOnceForTesting() } + var counts = await probe.counts() + XCTAssertEqual(counts.uploads, 1) + + await service.startForTesting(vmIP: "127.0.0.1", authToken: "test-token") + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 1, "Same-owner restart cannot mint a new cooldown allowance") + + clock.advance(30 * 60 + 1) + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 2) + + clock.advance(30 * 60 + 1) + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 2, "Failed recovery uploads stop at the existing bounded policy") + } - private func makeService(gate: AgentSyncReplacementCallbackGate) -> AgentSyncService { - AgentSyncService( - networkHooks: AgentSyncService.NetworkHooks( - fetchIDToken: { "test-firebase-token" }, - dataForRequest: { request in try await gate.respond(to: request) }, - reuploadDatabase: { vmIP, _ in await gate.reupload(vmIP: vmIP) }, - now: Date.init, - tableSyncEnabled: true)) - } + func testSameOwnerReplacementVMResetsOldRecoveryEvidenceCooldownAndBudget() async { + let probe = AgentSyncRecoveryProbe() + await probe.setUploadResults([false, false, false]) + let clock = AgentSyncManualClock() + let service = makeService(probe: probe, clock: clock) + await service.startForTesting(vmIP: "old-vm", authToken: "test-token") + + for _ in 0..<3 { await service.syncOnceForTesting() } + clock.advance(30 * 60 + 1) + await service.syncOnceForTesting() + var counts = await probe.counts() + XCTAssertEqual(counts.uploads, 2, "The old VM exhausts its bounded retry budget") + + await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") + await service.syncOnceForTesting() + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 2, "Replacement must not upload from stale old-VM evidence") + + await service.syncOnceForTesting() + counts = await probe.counts() + XCTAssertEqual(counts.uploads, 3, "Replacement gets a fresh causal threshold and bounded retry budget") + } - private func insertSyncRows() async throws { - guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { - return XCTFail("Rewind database should be initialized") + func testDelayedOldVMSyncResponseCannotCreateReplacementRecoveryEvidence() async { + let gate = AgentSyncReplacementCallbackGate(suspending: .sync) + let service = makeService(gate: gate) + await service.startForTesting(vmIP: "old-vm", authToken: "test-token") + let oldTick = Task { await service.syncOnceForTesting() } + await gate.waitUntilStarted(.sync) + + await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") + await gate.release(.sync) + await oldTick.value + for _ in 0..<3 { await service.syncOnceForTesting() } + + let uploadVMs = await gate.uploadVMs() + XCTAssertEqual(uploadVMs, ["replacement-vm"]) } - let now = Date(timeIntervalSince1970: 1_700_000_000) - try await dbQueue.write { db in - try db.execute( - sql: """ - INSERT INTO transcription_sessions (startedAt, source, createdAt, updatedAt) - VALUES (?, ?, ?, ?) - """, - arguments: [now, "desktop", now, now]) - try db.execute( - sql: """ - INSERT INTO action_items (description, createdAt, updatedAt) - VALUES (?, ?, ?) - """, - arguments: ["mixed-success control", now, now]) - try db.execute( - sql: """ - INSERT INTO memories (content, category, createdAt, updatedAt) - VALUES (?, ?, ?, ?) - """, - arguments: ["mixed-success recovery control", "system", now, now]) + + func testDelayedOldVMHealthResponseCannotSpendReplacementRecoveryBudget() async { + let gate = AgentSyncReplacementCallbackGate(suspending: .health) + let service = makeService(gate: gate) + await service.startForTesting(vmIP: "old-vm", authToken: "test-token") + await service.syncOnceForTesting() + await service.syncOnceForTesting() + let oldTick = Task { await service.syncOnceForTesting() } + await gate.waitUntilStarted(.health) + + await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") + await gate.release(.health) + await oldTick.value + for _ in 0..<3 { await service.syncOnceForTesting() } + + let uploadVMs = await gate.uploadVMs() + XCTAssertEqual(uploadVMs, ["replacement-vm"]) + } + + func testDelayedOldVMUploadResponseCannotClearReplacementRecoveryEvidence() async { + let gate = AgentSyncReplacementCallbackGate(suspending: .upload) + let service = makeService(gate: gate) + await service.startForTesting(vmIP: "old-vm", authToken: "test-token") + await service.syncOnceForTesting() + await service.syncOnceForTesting() + let oldTick = Task { await service.syncOnceForTesting() } + await gate.waitUntilStarted(.upload) + + await service.startForTesting(vmIP: "replacement-vm", authToken: "test-token") + await service.syncOnceForTesting() + await service.syncOnceForTesting() + await gate.release(.upload) + await oldTick.value + await service.syncOnceForTesting() + + let uploadVMs = await gate.uploadVMs() + XCTAssertEqual(uploadVMs, ["old-vm", "replacement-vm"]) + } + + private func makeService( + probe: AgentSyncRecoveryProbe, + clock: AgentSyncManualClock = AgentSyncManualClock() + ) -> AgentSyncService { + AgentSyncService( + networkHooks: AgentSyncService.NetworkHooks( + fetchIDToken: { "test-firebase-token" }, + dataForRequest: { request in try await probe.respond(to: request) }, + reuploadDatabase: { _, _ in await probe.reupload() }, + now: { clock.now() }, + tableSyncEnabled: true)) } - } - private func touchTranscriptionSession() async throws { - guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { - return XCTFail("Rewind database should be initialized") + private func makeService(gate: AgentSyncReplacementCallbackGate) -> AgentSyncService { + AgentSyncService( + networkHooks: AgentSyncService.NetworkHooks( + fetchIDToken: { "test-firebase-token" }, + dataForRequest: { request in try await gate.respond(to: request) }, + reuploadDatabase: { vmIP, _ in await gate.reupload(vmIP: vmIP) }, + now: Date.init, + tableSyncEnabled: true)) } - try await dbQueue.write { db in - try db.execute( - sql: "UPDATE transcription_sessions SET updatedAt = ? WHERE id = 1", - arguments: [Date(timeIntervalSince1970: 1_700_000_001)]) + + private func insertSyncRows() async throws { + guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { + return XCTFail("Rewind database should be initialized") + } + let now = Date(timeIntervalSince1970: 1_700_000_000) + try await dbQueue.write { db in + try db.execute( + sql: """ + INSERT INTO transcription_sessions (startedAt, source, createdAt, updatedAt) + VALUES (?, ?, ?, ?) + """, + arguments: [now, "desktop", now, now]) + try db.execute( + sql: """ + INSERT INTO action_items (description, createdAt, updatedAt) + VALUES (?, ?, ?) + """, + arguments: ["mixed-success control", now, now]) + try db.execute( + sql: """ + INSERT INTO memories (content, category, createdAt, updatedAt) + VALUES (?, ?, ?, ?) + """, + arguments: ["mixed-success recovery control", "system", now, now]) + } + } + + private func touchTranscriptionSession() async throws { + guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { + return XCTFail("Rewind database should be initialized") + } + try await dbQueue.write { db in + try db.execute( + sql: "UPDATE transcription_sessions SET updatedAt = ? WHERE id = 1", + arguments: [Date(timeIntervalSince1970: 1_700_000_001)]) + } } } -} +#endif From 7bb8d2457d9dead98b23a107f28f6e0f58337933 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Tue, 21 Jul 2026 23:34:02 -0400 Subject: [PATCH 03/11] test(desktop): gate DEBUG-seam VoiceTurnCoordinator tests for release compiles Same class of break as the previous commit, surfaced once AgentSyncRecoveryTests stopped masking it: VoiceTurnCoordinatorTests calls DesktopDiagnosticsManager.resetForTests(), which only exists under #if DEBUG (DesktopDiagnosticsManager.swift:866). Eight of the nine test files using that seam were already #if DEBUG-gated; this one was the recent exception, breaking the release-mode notification regression compile on every PR. Verification: - xcrun swift test -c release --filter UserNotificationCallbackBridgeTests/ (the exact CI step) now compiles the full test bundle and passes (2 tests). - Both gated suites still execute in debug: 37 tests, 0 failures (VoiceTurnCoordinatorTests 28, AgentSyncRecoveryTests 9). --- .../Tests/VoiceTurnCoordinatorTests.swift | 1362 +++++++++-------- 1 file changed, 684 insertions(+), 678 deletions(-) diff --git a/desktop/macos/Desktop/Tests/VoiceTurnCoordinatorTests.swift b/desktop/macos/Desktop/Tests/VoiceTurnCoordinatorTests.swift index 6eeeed0ad00..f3c7857b209 100644 --- a/desktop/macos/Desktop/Tests/VoiceTurnCoordinatorTests.swift +++ b/desktop/macos/Desktop/Tests/VoiceTurnCoordinatorTests.swift @@ -3,759 +3,765 @@ import XCTest @testable import Omi_Computer -@MainActor -final class VoiceTurnCoordinatorTests: XCTestCase { - func testTerminalTelemetryUsesThePhysicalTurnBoundary() throws { - DesktopDiagnosticsManager.shared.resetForTests() - defer { DesktopDiagnosticsManager.shared.resetForTests() } - - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.finish(turnID: turnID, reason: .providerNoResponse)) - - let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) - defer { try? FileManager.default.removeItem(at: url) } - let data = try Data(contentsOf: url) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) - let start = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_started" }) - let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) - - XCTAssertEqual(start["attempt_id"] as? String, turnID.description) - XCTAssertEqual(terminal["attempt_id"] as? String, turnID.description) - XCTAssertEqual(terminal["terminal_reason"] as? String, "provider_no_response") - XCTAssertEqual(terminal["outcome"] as? String, "failure") - XCTAssertEqual(terminal["response_outcome"] as? String, "failure") - XCTAssertNotNil(terminal["duration_ms"] as? Int) - } - - func testNestedDeferredHubCommitQueuesWithoutSynchronousStateMutation() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var sawQueuedPreCommitState = false - coordinator.setEffectHandler { effect in - guard case .finalizeCapturedInput(let turnID) = effect else { return } - XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) - - // The physical PTT finalizer runs while the coordinator drains this - // effect. Its commit-deferred event is deliberately FIFO-queued, so an - // immediate read still observes the pre-commit finalizing state. - coordinator.publish(.hubCommitDeferred(turnID: turnID)) - sawQueuedPreCommitState = coordinator.canCommitHubTurn(turnID) +#if DEBUG + // omi-release-compile: this suite drives DesktopDiagnosticsManager's DEBUG-only + // resetForTests seam; the release-mode notification regression step must + // compile the bundle without it. + + @MainActor + final class VoiceTurnCoordinatorTests: XCTestCase { + func testTerminalTelemetryUsesThePhysicalTurnBoundary() throws { + DesktopDiagnosticsManager.shared.resetForTests() + defer { DesktopDiagnosticsManager.shared.resetForTests() } + + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.finish(turnID: turnID, reason: .providerNoResponse)) + + let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) + defer { try? FileManager.default.removeItem(at: url) } + let data = try Data(contentsOf: url) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) + let start = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_started" }) + let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) + + XCTAssertEqual(start["attempt_id"] as? String, turnID.description) + XCTAssertEqual(terminal["attempt_id"] as? String, turnID.description) + XCTAssertEqual(terminal["terminal_reason"] as? String, "provider_no_response") + XCTAssertEqual(terminal["outcome"] as? String, "failure") + XCTAssertEqual(terminal["response_outcome"] as? String, "failure") + XCTAssertNotNil(terminal["duration_ms"] as? Int) } - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - coordinator.publish(.finalize(turnID: turnID)) + func testNestedDeferredHubCommitQueuesWithoutSynchronousStateMutation() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var sawQueuedPreCommitState = false + coordinator.setEffectHandler { effect in + guard case .finalizeCapturedInput(let turnID) = effect else { return } + XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) + + // The physical PTT finalizer runs while the coordinator drains this + // effect. Its commit-deferred event is deliberately FIFO-queued, so an + // immediate read still observes the pre-commit finalizing state. + coordinator.publish(.hubCommitDeferred(turnID: turnID)) + sawQueuedPreCommitState = coordinator.canCommitHubTurn(turnID) + } - XCTAssertTrue(sawQueuedPreCommitState) - XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) - XCTAssertTrue(coordinator.activeTurn?.hubCommitPending == true) - XCTAssertEqual(coordinator.model.invalidTransitionCount, 0) - } + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) + coordinator.publish(.finalize(turnID: turnID)) - func testNestedHubCommitClaimRunsProviderEffectAfterClaimStateIsApplied() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var providerEffectTurn: VoiceTurn? - coordinator.setEffectHandler { effect in - switch effect { - case .finalizeCapturedInput(let turnID): - // Match the physical PTT path: its finalization effect requests the - // hub claim while the coordinator is already draining effects. - coordinator.publish(.hubCommitClaimed(turnID: turnID)) - case .commitClaimedHubInput: - providerEffectTurn = coordinator.activeTurn - default: - break - } + XCTAssertTrue(sawQueuedPreCommitState) + XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) + XCTAssertTrue(coordinator.activeTurn?.hubCommitPending == true) + XCTAssertEqual(coordinator.model.invalidTransitionCount, 0) } - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - coordinator.publish(.finalize(turnID: turnID)) + func testNestedHubCommitClaimRunsProviderEffectAfterClaimStateIsApplied() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var providerEffectTurn: VoiceTurn? + coordinator.setEffectHandler { effect in + switch effect { + case .finalizeCapturedInput(let turnID): + // Match the physical PTT path: its finalization effect requests the + // hub claim while the coordinator is already draining effects. + coordinator.publish(.hubCommitClaimed(turnID: turnID)) + case .commitClaimedHubInput: + providerEffectTurn = coordinator.activeTurn + default: + break + } + } - XCTAssertEqual(providerEffectTurn?.id, turnID) - XCTAssertEqual(providerEffectTurn?.phase, .awaitingResponse) - XCTAssertTrue(providerEffectTurn?.hubCommitPending == true) - } + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) + coordinator.publish(.finalize(turnID: turnID)) - func testAutomationFinalizeRemainsCommitableWithoutPhysicalCaptureBuffer() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var physicalFinalizeCount = 0 - coordinator.setEffectHandler { effect in - guard case .finalizeCapturedInput(let turnID) = effect else { return } - if PushToTalkManager.shouldFinalizeCapturedInputPhysically( - turnIntent: coordinator.activeTurn?.intent) - { - physicalFinalizeCount += 1 - coordinator.publish(.finish(turnID: turnID, reason: .tooShort)) - } + XCTAssertEqual(providerEffectTurn?.id, turnID) + XCTAssertEqual(providerEffectTurn?.phase, .awaitingResponse) + XCTAssertTrue(providerEffectTurn?.hubCommitPending == true) } - let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - coordinator.publish(.finalize(turnID: turnID)) + func testAutomationFinalizeRemainsCommitableWithoutPhysicalCaptureBuffer() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var physicalFinalizeCount = 0 + coordinator.setEffectHandler { effect in + guard case .finalizeCapturedInput(let turnID) = effect else { return } + if PushToTalkManager.shouldFinalizeCapturedInputPhysically( + turnIntent: coordinator.activeTurn?.intent) + { + physicalFinalizeCount += 1 + coordinator.publish(.finish(turnID: turnID, reason: .tooShort)) + } + } + let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - XCTAssertEqual(physicalFinalizeCount, 0) - XCTAssertEqual(coordinator.activeTurn?.phase, .finalizing) - XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) - coordinator.publish(.hubCommitClaimed(turnID: turnID)) - XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) - } + coordinator.publish(.finalize(turnID: turnID)) - func testAutomationHarnessSatisfiesCaptureStartDeadline() { - let scheduler = ManualVoiceTurnScheduler() - let coordinator = VoiceTurnCoordinator(scheduler: scheduler) + XCTAssertEqual(physicalFinalizeCount, 0) + XCTAssertEqual(coordinator.activeTurn?.phase, .finalizing) + XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) + coordinator.publish(.hubCommitClaimed(turnID: turnID)) + XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) + } - let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) - scheduler.fire(deadline: .captureStart) + func testAutomationHarnessSatisfiesCaptureStartDeadline() { + let scheduler = ManualVoiceTurnScheduler() + let coordinator = VoiceTurnCoordinator(scheduler: scheduler) - XCTAssertEqual(coordinator.activeTurnID, turnID) - XCTAssertEqual(coordinator.activeTurn?.captureID, VoiceCaptureID(1)) - XCTAssertEqual(coordinator.activeTurn?.phase, .recording) - } + let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) + scheduler.fire(deadline: .captureStart) - func testFakeClockDrivesLockDeadlineAndRealStopCaptureEffect() { - let scheduler = ManualVoiceTurnScheduler() - let coordinator = VoiceTurnCoordinator(scheduler: scheduler) - var effects: [VoiceTurnEffect] = [] - coordinator.setEffectHandler { effects.append($0) } - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.captureStarted(turnID: turnID, captureID: VoiceCaptureID(1))) - coordinator.publish(.openLockWindow(turnID: turnID)) + XCTAssertEqual(coordinator.activeTurnID, turnID) + XCTAssertEqual(coordinator.activeTurn?.captureID, VoiceCaptureID(1)) + XCTAssertEqual(coordinator.activeTurn?.phase, .recording) + } - scheduler.fire(deadline: .lockDecision) + func testFakeClockDrivesLockDeadlineAndRealStopCaptureEffect() { + let scheduler = ManualVoiceTurnScheduler() + let coordinator = VoiceTurnCoordinator(scheduler: scheduler) + var effects: [VoiceTurnEffect] = [] + coordinator.setEffectHandler { effects.append($0) } + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.captureStarted(turnID: turnID, captureID: VoiceCaptureID(1))) + coordinator.publish(.openLockWindow(turnID: turnID)) - XCTAssertEqual(coordinator.model.turn?.phase, .finalizing) - XCTAssertTrue( - effects.contains(.stopCapture(turnID: turnID, captureID: VoiceCaptureID(1)))) - } + scheduler.fire(deadline: .lockDecision) - func testCancelledDeadlineCannotMutateLaterTurn() { - let scheduler = ManualVoiceTurnScheduler() - let coordinator = VoiceTurnCoordinator(scheduler: scheduler) - let oldTurn = coordinator.begin(intent: .hold) - coordinator.publish(.openLockWindow(turnID: oldTurn)) - let newTurn = coordinator.begin(intent: .hold) + XCTAssertEqual(coordinator.model.turn?.phase, .finalizing) + XCTAssertTrue( + effects.contains(.stopCapture(turnID: turnID, captureID: VoiceCaptureID(1)))) + } - scheduler.fire(deadline: .lockDecision) + func testCancelledDeadlineCannotMutateLaterTurn() { + let scheduler = ManualVoiceTurnScheduler() + let coordinator = VoiceTurnCoordinator(scheduler: scheduler) + let oldTurn = coordinator.begin(intent: .hold) + coordinator.publish(.openLockWindow(turnID: oldTurn)) + let newTurn = coordinator.begin(intent: .hold) - XCTAssertEqual(coordinator.activeTurnID, newTurn) - XCTAssertEqual(coordinator.model.turn?.phase, .recording) - } + scheduler.fire(deadline: .lockDecision) - func testTimelineReconstructsTurnAndIsBounded() { - let coordinator = VoiceTurnCoordinator( - scheduler: ManualVoiceTurnScheduler(), - timelineLimit: 4) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.captureStarted(turnID: turnID, captureID: VoiceCaptureID(1))) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - - let timeline = coordinator.timelineSnapshot() - XCTAssertEqual(timeline.count, 4) - XCTAssertEqual(timeline.last?.turnID, turnID) - XCTAssertEqual(timeline.last?.phaseAfter, .finalizing) - XCTAssertEqual(timeline.last?.route, .deepgramBatch) - } - - func testPresenterDerivesConsistentListeningThinkingAndTerminalUI() { - let defaultsKey = "hasCompletedOnboarding" - let previous = UserDefaults.standard.object(forKey: defaultsKey) - UserDefaults.standard.set(false, forKey: defaultsKey) - defer { - if let previous { - UserDefaults.standard.set(previous, forKey: defaultsKey) - } else { - UserDefaults.standard.removeObject(forKey: defaultsKey) - } + XCTAssertEqual(coordinator.activeTurnID, newTurn) + XCTAssertEqual(coordinator.model.turn?.phase, .recording) } - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let barState = FloatingControlBarState() - coordinator.configure(barState: barState) - let turnID = coordinator.begin(intent: .hold) - XCTAssertTrue(barState.isVoiceListening) - XCTAssertFalse(barState.isThinking) - - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - XCTAssertFalse(barState.isVoiceListening) - XCTAssertTrue(barState.isThinking) - XCTAssertEqual(barState.voiceTranscript, "Transcribing…") - XCTAssertEqual(barState.pttHintText, "") - - coordinator.publish(.transcriptionFailed(turnID: turnID, message: "fixture")) - // The capture/listening phase is over, but the pill remains expanded long - // enough to make the actionable terminal hint visible to the user. - XCTAssertTrue(barState.isVoiceListening) - XCTAssertFalse(barState.isThinking) - XCTAssertFalse(barState.isVoiceResponseActive) - XCTAssertEqual(barState.pttHintText, "Couldn't transcribe that — try again") - } - - func testAwaitingResponseRemainsThinkingUntilReducerAdvances() throws { - let scheduler = ManualVoiceTurnScheduler() - let coordinator = VoiceTurnCoordinator(scheduler: scheduler) - let barState = FloatingControlBarState() - coordinator.configure(barState: barState) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionFinal(turnID: turnID, text: "find today's memories")) - - XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) - XCTAssertTrue(coordinator.projection.isThinking) - XCTAssertTrue(barState.isThinking) - - coordinator.refreshPresentation() - XCTAssertTrue(barState.isThinking) - - let identity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) - coordinator.publish( - .providerResponseStartedScoped( - turnID: turnID, - identity: identity, - sessionID: nil, - responseID: nil)) - - XCTAssertFalse(coordinator.projection.isThinking) - XCTAssertFalse(barState.isThinking) - } + func testTimelineReconstructsTurnAndIsBounded() { + let coordinator = VoiceTurnCoordinator( + scheduler: ManualVoiceTurnScheduler(), + timelineLimit: 4) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.captureStarted(turnID: turnID, captureID: VoiceCaptureID(1))) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + + let timeline = coordinator.timelineSnapshot() + XCTAssertEqual(timeline.count, 4) + XCTAssertEqual(timeline.last?.turnID, turnID) + XCTAssertEqual(timeline.last?.phaseAfter, .finalizing) + XCTAssertEqual(timeline.last?.route, .deepgramBatch) + } - func testTerminalEffectAndCleanupAreExactlyOnce() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var terminals: [VoiceTurnTerminalRecord] = [] - coordinator.setEffectHandler { effect in - if case .terminal(let terminal) = effect { - terminals.append(terminal) + func testPresenterDerivesConsistentListeningThinkingAndTerminalUI() { + let defaultsKey = "hasCompletedOnboarding" + let previous = UserDefaults.standard.object(forKey: defaultsKey) + UserDefaults.standard.set(false, forKey: defaultsKey) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } } + + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let barState = FloatingControlBarState() + coordinator.configure(barState: barState) + let turnID = coordinator.begin(intent: .hold) + XCTAssertTrue(barState.isVoiceListening) + XCTAssertFalse(barState.isThinking) + + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + XCTAssertFalse(barState.isVoiceListening) + XCTAssertTrue(barState.isThinking) + XCTAssertEqual(barState.voiceTranscript, "Transcribing…") + XCTAssertEqual(barState.pttHintText, "") + + coordinator.publish(.transcriptionFailed(turnID: turnID, message: "fixture")) + // The capture/listening phase is over, but the pill remains expanded long + // enough to make the actionable terminal hint visible to the user. + XCTAssertTrue(barState.isVoiceListening) + XCTAssertFalse(barState.isThinking) + XCTAssertFalse(barState.isVoiceResponseActive) + XCTAssertEqual(barState.pttHintText, "Couldn't transcribe that — try again") } - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.cancel(turnID: turnID, reason: .cancelled)) - coordinator.publish(.finish(turnID: turnID, reason: .providerFailed)) + func testAwaitingResponseRemainsThinkingUntilReducerAdvances() throws { + let scheduler = ManualVoiceTurnScheduler() + let coordinator = VoiceTurnCoordinator(scheduler: scheduler) + let barState = FloatingControlBarState() + coordinator.configure(barState: barState) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionFinal(turnID: turnID, text: "find today's memories")) - XCTAssertEqual(terminals, [.init(turnID: turnID, reason: .cancelled)]) - XCTAssertEqual(coordinator.model.duplicateTerminalCount, 1) - } + XCTAssertEqual(coordinator.activeTurn?.phase, .awaitingResponse) + XCTAssertTrue(coordinator.projection.isThinking) + XCTAssertTrue(barState.isThinking) - func testUnscopedPlaybackUsesPresenterButCannotOverrideActivePTTTurn() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let barState = FloatingControlBarState() - coordinator.configure(barState: barState) - - coordinator.setUnscopedResponseActive(true) - XCTAssertTrue(barState.isVoiceResponseActive) - coordinator.setUnscopedResponseActive(false) - XCTAssertFalse(barState.isVoiceResponseActive) - - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) - guard let providerIdentity = coordinator.activeTurn?.providerEffectIdentity else { - return XCTFail("transcription final must mint a provider identity") - } - coordinator.publish( - .providerResponseStartedScoped( - turnID: turnID, - identity: providerIdentity, - sessionID: nil, - responseID: nil)) - XCTAssertTrue(barState.isVoiceResponseActive) - - coordinator.setUnscopedResponseActive(false) - XCTAssertTrue( - barState.isVoiceResponseActive, "late non-PTT playback must not clear the active turn") - } + coordinator.refreshPresentation() + XCTAssertTrue(barState.isThinking) - func testSnapshotHandlerReceivesInitialAndSubsequentAuthoritativeModels() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var snapshots: [VoiceTurnModel] = [] - coordinator.setSnapshotHandler { snapshots.append($0) } + let identity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) + coordinator.publish( + .providerResponseStartedScoped( + turnID: turnID, + identity: identity, + sessionID: nil, + responseID: nil)) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.lock(turnID: turnID)) + XCTAssertFalse(coordinator.projection.isThinking) + XCTAssertFalse(barState.isThinking) + } - XCTAssertEqual(snapshots.first, .idle) - XCTAssertEqual(snapshots.last?.turn?.phase, .lockedRecording) - XCTAssertEqual(snapshots.count, 3) - } + func testTerminalEffectAndCleanupAreExactlyOnce() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var terminals: [VoiceTurnTerminalRecord] = [] + coordinator.setEffectHandler { effect in + if case .terminal(let terminal) = effect { + terminals.append(terminal) + } + } + let turnID = coordinator.begin(intent: .hold) - func testHubReadyTransitionIsConsumedBeforeReentrantSnapshot() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let sessionID = VoiceSessionID() - var resolutions = 0 - coordinator.setEffectHandler { effect in - guard case .prepareHubInput(let turnID, let preparedSessionID) = effect else { return } - XCTAssertEqual(preparedSessionID, sessionID) - resolutions += 1 - // RealtimeHubController.beginTurn clears its response glow synchronously, - // which publishes another snapshot. The consumed transition must not run - // the warm-wait resolver again. - coordinator.publish(.responseActiveChanged(turnID: turnID, active: false)) - } - - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hubWarmWait)) - coordinator.publish(.hubReady(turnID: turnID, sessionID: sessionID)) - - XCTAssertEqual(resolutions, 1) - XCTAssertEqual(coordinator.model.turn?.route, .hub(sessionID: sessionID)) - XCTAssertEqual( - coordinator.timelineSnapshot().filter { $0.event == "hub_ready" }.count, - 1) - } + coordinator.publish(.cancel(turnID: turnID, reason: .cancelled)) + coordinator.publish(.finish(turnID: turnID, reason: .providerFailed)) - func testPrepareHubInputCanReserveContextAdmissionIdentityWhileEffectsDrainFIFO() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let sessionID = VoiceSessionID() - var reservedIdentity: VoiceEffectIdentity? - - coordinator.setEffectHandler { effect in - guard case .prepareHubInput(let turnID, _) = effect else { return } - // This is the real PTT path: `hubReady` emits prepareHubInput, whose - // handler starts a context-fresh admission while the coordinator remains - // inside its FIFO effect drain. - let identity = coordinator.reserveEffectIdentity() - reservedIdentity = identity - guard let identity else { return } + XCTAssertEqual(terminals, [.init(turnID: turnID, reason: .cancelled)]) + XCTAssertEqual(coordinator.model.duplicateTerminalCount, 1) + } + + func testUnscopedPlaybackUsesPresenterButCannotOverrideActivePTTTurn() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let barState = FloatingControlBarState() + coordinator.configure(barState: barState) + + coordinator.setUnscopedResponseActive(true) + XCTAssertTrue(barState.isVoiceResponseActive) + coordinator.setUnscopedResponseActive(false) + XCTAssertFalse(barState.isVoiceResponseActive) + + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) + guard let providerIdentity = coordinator.activeTurn?.providerEffectIdentity else { + return XCTFail("transcription final must mint a provider identity") + } coordinator.publish( - .providerReconnectStarted( + .providerResponseStartedScoped( turnID: turnID, - identity: identity, - previousSessionID: nil)) + identity: providerIdentity, + sessionID: nil, + responseID: nil)) + XCTAssertTrue(barState.isVoiceResponseActive) + + coordinator.setUnscopedResponseActive(false) + XCTAssertTrue( + barState.isVoiceResponseActive, "late non-PTT playback must not clear the active turn") } - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hubWarmWait)) - coordinator.publish(.hubReady(turnID: turnID, sessionID: sessionID)) + func testSnapshotHandlerReceivesInitialAndSubsequentAuthoritativeModels() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var snapshots: [VoiceTurnModel] = [] + coordinator.setSnapshotHandler { snapshots.append($0) } - guard let reservedIdentity else { - return XCTFail("prepareHubInput must reserve an identity while effects are draining") + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.lock(turnID: turnID)) + + XCTAssertEqual(snapshots.first, .idle) + XCTAssertEqual(snapshots.last?.turn?.phase, .lockedRecording) + XCTAssertEqual(snapshots.count, 3) } - XCTAssertEqual(reservedIdentity.generation, turnID.rawValue) - guard let connection = coordinator.activeTurn?.providerConnection, - case .reconnecting(let appliedIdentity, let previousSessionID) = connection - else { - return XCTFail("the queued context-admission event must be applied after the effect returns") + + func testHubReadyTransitionIsConsumedBeforeReentrantSnapshot() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let sessionID = VoiceSessionID() + var resolutions = 0 + coordinator.setEffectHandler { effect in + guard case .prepareHubInput(let turnID, let preparedSessionID) = effect else { return } + XCTAssertEqual(preparedSessionID, sessionID) + resolutions += 1 + // RealtimeHubController.beginTurn clears its response glow synchronously, + // which publishes another snapshot. The consumed transition must not run + // the warm-wait resolver again. + coordinator.publish(.responseActiveChanged(turnID: turnID, active: false)) + } + + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hubWarmWait)) + coordinator.publish(.hubReady(turnID: turnID, sessionID: sessionID)) + + XCTAssertEqual(resolutions, 1) + XCTAssertEqual(coordinator.model.turn?.route, .hub(sessionID: sessionID)) + XCTAssertEqual( + coordinator.timelineSnapshot().filter { $0.event == "hub_ready" }.count, + 1) } - XCTAssertEqual(appliedIdentity, reservedIdentity) - XCTAssertNil(previousSessionID) - XCTAssertEqual(coordinator.model.invalidTransitionCount, 0) - } - func testSnapshotReentrantEventsDrainFIFOWithoutRecursiveCallbacks() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var callbackDepth = 0 - var maximumCallbackDepth = 0 - var queuedRouteSelection = false + func testPrepareHubInputCanReserveContextAdmissionIdentityWhileEffectsDrainFIFO() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let sessionID = VoiceSessionID() + var reservedIdentity: VoiceEffectIdentity? + + coordinator.setEffectHandler { effect in + guard case .prepareHubInput(let turnID, _) = effect else { return } + // This is the real PTT path: `hubReady` emits prepareHubInput, whose + // handler starts a context-fresh admission while the coordinator remains + // inside its FIFO effect drain. + let identity = coordinator.reserveEffectIdentity() + reservedIdentity = identity + guard let identity else { return } + coordinator.publish( + .providerReconnectStarted( + turnID: turnID, + identity: identity, + previousSessionID: nil)) + } + + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hubWarmWait)) + coordinator.publish(.hubReady(turnID: turnID, sessionID: sessionID)) - coordinator.setSnapshotHandler { model in - callbackDepth += 1 - maximumCallbackDepth = max(maximumCallbackDepth, callbackDepth) - defer { callbackDepth -= 1 } + guard let reservedIdentity else { + return XCTFail("prepareHubInput must reserve an identity while effects are draining") + } + XCTAssertEqual(reservedIdentity.generation, turnID.rawValue) + guard let connection = coordinator.activeTurn?.providerConnection, + case .reconnecting(let appliedIdentity, let previousSessionID) = connection + else { + return XCTFail("the queued context-admission event must be applied after the effect returns") + } + XCTAssertEqual(appliedIdentity, reservedIdentity) + XCTAssertNil(previousSessionID) + XCTAssertEqual(coordinator.model.invalidTransitionCount, 0) + } - guard !queuedRouteSelection, let turn = model.turn, turn.phase == .recording else { - return + func testSnapshotReentrantEventsDrainFIFOWithoutRecursiveCallbacks() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var callbackDepth = 0 + var maximumCallbackDepth = 0 + var queuedRouteSelection = false + + coordinator.setSnapshotHandler { model in + callbackDepth += 1 + maximumCallbackDepth = max(maximumCallbackDepth, callbackDepth) + defer { callbackDepth -= 1 } + + guard !queuedRouteSelection, let turn = model.turn, turn.phase == .recording else { + return + } + queuedRouteSelection = true + coordinator.publish(.selectRoute(turnID: turn.id, route: .deepgramBatch)) + + XCTAssertEqual( + coordinator.model.turn?.route, + .undecided, + "a nested event must not mutate the model until the current snapshot returns" + ) } - queuedRouteSelection = true - coordinator.publish(.selectRoute(turnID: turn.id, route: .deepgramBatch)) + let turnID = coordinator.begin(intent: .hold) + + XCTAssertEqual(maximumCallbackDepth, 1) + XCTAssertEqual(coordinator.model.turn?.route, .deepgramBatch) XCTAssertEqual( - coordinator.model.turn?.route, - .undecided, - "a nested event must not mutate the model until the current snapshot returns" + coordinator.timelineSnapshot().suffix(2).map(\.event), + ["start", "select_route"] ) + XCTAssertEqual(coordinator.activeTurnID, turnID) } - let turnID = coordinator.begin(intent: .hold) - - XCTAssertEqual(maximumCallbackDepth, 1) - XCTAssertEqual(coordinator.model.turn?.route, .deepgramBatch) - XCTAssertEqual( - coordinator.timelineSnapshot().suffix(2).map(\.event), - ["start", "select_route"] - ) - XCTAssertEqual(coordinator.activeTurnID, turnID) - } - - func testEffectReentrantTerminalEventRunsAfterCurrentEffectReturns() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - let captureID = VoiceCaptureID(91) - coordinator.publish(.captureStarted(turnID: turnID, captureID: captureID)) - - var callbackDepth = 0 - var maximumCallbackDepth = 0 - var queuedCancellation = false - var effects: [VoiceTurnEffect] = [] - coordinator.setEffectHandler { effect in - callbackDepth += 1 - maximumCallbackDepth = max(maximumCallbackDepth, callbackDepth) - effects.append(effect) - defer { callbackDepth -= 1 } - - guard !queuedCancellation, effect == .stopCapture(turnID: turnID, captureID: captureID) else { - return + func testEffectReentrantTerminalEventRunsAfterCurrentEffectReturns() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + let captureID = VoiceCaptureID(91) + coordinator.publish(.captureStarted(turnID: turnID, captureID: captureID)) + + var callbackDepth = 0 + var maximumCallbackDepth = 0 + var queuedCancellation = false + var effects: [VoiceTurnEffect] = [] + coordinator.setEffectHandler { effect in + callbackDepth += 1 + maximumCallbackDepth = max(maximumCallbackDepth, callbackDepth) + effects.append(effect) + defer { callbackDepth -= 1 } + + guard !queuedCancellation, effect == .stopCapture(turnID: turnID, captureID: captureID) else { + return + } + queuedCancellation = true + coordinator.publish(.cancel(turnID: turnID, reason: .cancelled)) + + XCTAssertEqual( + coordinator.model.turn?.phase, + .finalizing, + "a nested terminal event must wait until the current effect returns" + ) } - queuedCancellation = true - coordinator.publish(.cancel(turnID: turnID, reason: .cancelled)) + coordinator.publish(.finalize(turnID: turnID)) + + XCTAssertEqual(maximumCallbackDepth, 1) + XCTAssertEqual(coordinator.model.turn?.phase, .terminal(.cancelled)) + XCTAssertEqual( + effects.compactMap { effect -> VoiceTurnTerminalRecord? in + if case .terminal(let terminal) = effect { return terminal } + return nil + }, + [.init(turnID: turnID, reason: .cancelled)] + ) XCTAssertEqual( - coordinator.model.turn?.phase, - .finalizing, - "a nested terminal event must wait until the current effect returns" + coordinator.timelineSnapshot().suffix(2).map(\.event), + ["finalize", "cancel"] ) } - coordinator.publish(.finalize(turnID: turnID)) + func testResetCancelsOutstandingDeadlinesAndReturnsPresentationToIdle() { + let scheduler = ManualVoiceTurnScheduler() + let coordinator = VoiceTurnCoordinator(scheduler: scheduler) + let barState = FloatingControlBarState() + coordinator.configure(barState: barState) + _ = coordinator.begin(intent: .hold) + XCTAssertTrue(barState.isVoiceListening) + XCTAssertGreaterThan(scheduler.activeCount, 0) + + coordinator.reset() + + XCTAssertNil(coordinator.activeTurn) + XCTAssertNil(coordinator.model.turn) + XCTAssertFalse(barState.isVoiceListening) + XCTAssertEqual(scheduler.activeCount, 0) + } - XCTAssertEqual(maximumCallbackDepth, 1) - XCTAssertEqual(coordinator.model.turn?.phase, .terminal(.cancelled)) - XCTAssertEqual( - effects.compactMap { effect -> VoiceTurnTerminalRecord? in - if case .terminal(let terminal) = effect { return terminal } - return nil - }, - [.init(turnID: turnID, reason: .cancelled)] - ) - XCTAssertEqual( - coordinator.timelineSnapshot().suffix(2).map(\.event), - ["finalize", "cancel"] - ) - } + func testStaleAndInvalidTransitionsRemainObservableEffects() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + var effects: [VoiceTurnEffect] = [] + coordinator.setEffectHandler { effects.append($0) } + let turnID = coordinator.begin(intent: .hold) - func testResetCancelsOutstandingDeadlinesAndReturnsPresentationToIdle() { - let scheduler = ManualVoiceTurnScheduler() - let coordinator = VoiceTurnCoordinator(scheduler: scheduler) - let barState = FloatingControlBarState() - coordinator.configure(barState: barState) - _ = coordinator.begin(intent: .hold) - XCTAssertTrue(barState.isVoiceListening) - XCTAssertGreaterThan(scheduler.activeCount, 0) - - coordinator.reset() - - XCTAssertNil(coordinator.activeTurn) - XCTAssertNil(coordinator.model.turn) - XCTAssertFalse(barState.isVoiceListening) - XCTAssertEqual(scheduler.activeCount, 0) - } + coordinator.publish(.finalize(turnID: VoiceTurnID())) + coordinator.publish( + .hubCommitAccepted(turnID: turnID, sessionID: VoiceSessionID(), responseID: nil)) + + XCTAssertTrue( + effects.contains(where: { + if case .staleEventDropped = $0 { return true } + return false + })) + XCTAssertTrue( + effects.contains(where: { + if case .invalidTransition = $0 { return true } + return false + })) + XCTAssertEqual(coordinator.model.staleEventCount, 1) + XCTAssertEqual(coordinator.model.invalidTransitionCount, 1) + } - func testStaleAndInvalidTransitionsRemainObservableEffects() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - var effects: [VoiceTurnEffect] = [] - coordinator.setEffectHandler { effects.append($0) } - let turnID = coordinator.begin(intent: .hold) - - coordinator.publish(.finalize(turnID: VoiceTurnID())) - coordinator.publish( - .hubCommitAccepted(turnID: turnID, sessionID: VoiceSessionID(), responseID: nil)) - - XCTAssertTrue( - effects.contains(where: { - if case .staleEventDropped = $0 { return true } - return false - })) - XCTAssertTrue( - effects.contains(where: { - if case .invalidTransition = $0 { return true } - return false - })) - XCTAssertEqual(coordinator.model.staleEventCount, 1) - XCTAssertEqual(coordinator.model.invalidTransitionCount, 1) - } + func testDiagnosticLabelsAreStableAndLowCardinality() { + let turnID = VoiceTurnID() + let phases: [(VoiceTurnPhase, String)] = [ + (.idle, "idle"), + (.pendingLockDecision, "pending_lock_decision"), + (.recording, "recording"), + (.lockedRecording, "locked_recording"), + (.finalizing, "finalizing"), + (.awaitingResponse, "awaiting_response"), + (.awaitingTools, "awaiting_tools"), + (.playing(.filler), "playing_filler"), + (.terminal(.providerFailed), "terminal_provider_failed"), + ] + for (phase, expected) in phases { + XCTAssertEqual(VoiceTurnCoordinator.phaseLabel(phase), expected) + } - func testDiagnosticLabelsAreStableAndLowCardinality() { - let turnID = VoiceTurnID() - let phases: [(VoiceTurnPhase, String)] = [ - (.idle, "idle"), - (.pendingLockDecision, "pending_lock_decision"), - (.recording, "recording"), - (.lockedRecording, "locked_recording"), - (.finalizing, "finalizing"), - (.awaitingResponse, "awaiting_response"), - (.awaitingTools, "awaiting_tools"), - (.playing(.filler), "playing_filler"), - (.terminal(.providerFailed), "terminal_provider_failed"), - ] - for (phase, expected) in phases { - XCTAssertEqual(VoiceTurnCoordinator.phaseLabel(phase), expected) - } - - let routes: [(VoiceTurnRoute, String)] = [ - (.undecided, "undecided"), - (.hubWarmWait, "hub_warm_wait"), - (.hub(sessionID: VoiceSessionID()), "hub"), - (.omniSTT, "omni_stt"), - (.deepgramBatch, "deepgram_batch"), - (.deepgramLive, "deepgram_live"), - ] - for (route, expected) in routes { - XCTAssertEqual(VoiceTurnCoordinator.routeLabel(route), expected, "turn=\(turnID)") + let routes: [(VoiceTurnRoute, String)] = [ + (.undecided, "undecided"), + (.hubWarmWait, "hub_warm_wait"), + (.hub(sessionID: VoiceSessionID()), "hub"), + (.omniSTT, "omni_stt"), + (.deepgramBatch, "deepgram_batch"), + (.deepgramLive, "deepgram_live"), + ] + for (route, expected) in routes { + XCTAssertEqual(VoiceTurnCoordinator.routeLabel(route), expected, "turn=\(turnID)") + } } - } - func testTimelineNeverStoresAssociatedSpeechPayloads() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let marker = "secret-timeline-marker-442" - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.transcriptChanged(turnID: turnID, text: marker)) - let staleID = VoiceTurnID() - coordinator.publish( - .playbackFailedScoped( - turnID: staleID, - identity: VoiceEffectIdentity(turnID: staleID, effectID: 1), - leaseID: nil, - message: marker)) - - let events = coordinator.timelineSnapshot().map(\.event) - XCTAssertTrue(events.contains("transcript_changed")) - XCTAssertTrue(events.contains("playback_failed_scoped")) - XCTAssertFalse(events.joined().contains(marker)) - } + func testTimelineNeverStoresAssociatedSpeechPayloads() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let marker = "secret-timeline-marker-442" + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.transcriptChanged(turnID: turnID, text: marker)) + let staleID = VoiceTurnID() + coordinator.publish( + .playbackFailedScoped( + turnID: staleID, + identity: VoiceEffectIdentity(turnID: staleID, effectID: 1), + leaseID: nil, + message: marker)) + + let events = coordinator.timelineSnapshot().map(\.event) + XCTAssertTrue(events.contains("transcript_changed")) + XCTAssertTrue(events.contains("playback_failed_scoped")) + XCTAssertFalse(events.joined().contains(marker)) + } - func testNonHubJournalAcceptanceWaitsForIndependentPlaybackFence() throws { - DesktopDiagnosticsManager.shared.resetForTests() - defer { DesktopDiagnosticsManager.shared.resetForTests() } - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) - let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) - coordinator.publish( - .providerResponseStartedScoped( - turnID: turnID, - identity: providerIdentity, - sessionID: nil, - responseID: nil)) - guard - case .acquired(let lease) = coordinator.acquireOutput( - .selectedVoiceFallback, turnID: turnID) - else { return XCTFail("expected output lease") } - let token = try XCTUnwrap(coordinator.nonHubCompletionToken(for: turnID)) - - XCTAssertTrue(coordinator.completeNonHubProvider(token, outcome: .journalAccepted)) - XCTAssertEqual(coordinator.activeTurn?.phase, .playing(.selectedVoiceFallback)) - XCTAssertTrue(coordinator.activeTurn?.providerFinished == true) - guard case .accepted = coordinator.activeTurn?.journalFinalization else { - return XCTFail("canonical journal acceptance must be retained while playback drains") - } - - XCTAssertTrue(coordinator.releaseOutput(lease)) - XCTAssertEqual(coordinator.model.lastTerminal?.turnID, turnID) - XCTAssertEqual(coordinator.model.lastTerminal?.reason, .success) - - let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) - defer { try? FileManager.default.removeItem(at: url) } - let data = try Data(contentsOf: url) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) - let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) - XCTAssertEqual(terminal["response_outcome"] as? String, "success") - XCTAssertNotNil(terminal["duration_ms"] as? Int) - } + func testNonHubJournalAcceptanceWaitsForIndependentPlaybackFence() throws { + DesktopDiagnosticsManager.shared.resetForTests() + defer { DesktopDiagnosticsManager.shared.resetForTests() } + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) + let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) + coordinator.publish( + .providerResponseStartedScoped( + turnID: turnID, + identity: providerIdentity, + sessionID: nil, + responseID: nil)) + guard + case .acquired(let lease) = coordinator.acquireOutput( + .selectedVoiceFallback, turnID: turnID) + else { return XCTFail("expected output lease") } + let token = try XCTUnwrap(coordinator.nonHubCompletionToken(for: turnID)) + + XCTAssertTrue(coordinator.completeNonHubProvider(token, outcome: .journalAccepted)) + XCTAssertEqual(coordinator.activeTurn?.phase, .playing(.selectedVoiceFallback)) + XCTAssertTrue(coordinator.activeTurn?.providerFinished == true) + guard case .accepted = coordinator.activeTurn?.journalFinalization else { + return XCTFail("canonical journal acceptance must be retained while playback drains") + } - func testDeliveredVoiceAnswerDoesNotBecomeResponseFailureWhenJournalFailsLater() throws { - DesktopDiagnosticsManager.shared.resetForTests() - defer { DesktopDiagnosticsManager.shared.resetForTests() } - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) - let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) - coordinator.publish( - .providerResponseStartedScoped( - turnID: turnID, - identity: providerIdentity, - sessionID: nil, - responseID: nil)) - guard - case .acquired(let lease) = coordinator.acquireOutput( - .selectedVoiceFallback, turnID: turnID) - else { return XCTFail("expected output lease") } - let token = try XCTUnwrap(coordinator.nonHubCompletionToken(for: turnID)) - - XCTAssertTrue(coordinator.releaseOutput(lease)) - XCTAssertTrue(coordinator.completeNonHubProvider(token, outcome: .journalFailed)) - XCTAssertEqual(coordinator.model.lastTerminal?.reason, .journalFailed) - - let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) - defer { try? FileManager.default.removeItem(at: url) } - let data = try Data(contentsOf: url) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) - let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) - XCTAssertEqual(terminal["outcome"] as? String, "failure") - XCTAssertEqual(terminal["response_outcome"] as? String, "success") - } + XCTAssertTrue(coordinator.releaseOutput(lease)) + XCTAssertEqual(coordinator.model.lastTerminal?.turnID, turnID) + XCTAssertEqual(coordinator.model.lastTerminal?.reason, .success) + + let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) + defer { try? FileManager.default.removeItem(at: url) } + let data = try Data(contentsOf: url) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) + let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) + XCTAssertEqual(terminal["response_outcome"] as? String, "success") + XCTAssertNotNil(terminal["duration_ms"] as? Int) + } - func testFillerDrainDoesNotHideLaterVoicePlaybackFailure() throws { - DesktopDiagnosticsManager.shared.resetForTests() - defer { DesktopDiagnosticsManager.shared.resetForTests() } - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.transcriptionStarted(turnID: turnID)) - coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) - let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) - coordinator.publish( - .providerResponseStartedScoped( - turnID: turnID, - identity: providerIdentity, - sessionID: nil, - responseID: nil)) - - guard case .acquired(let filler) = coordinator.acquireOutput(.filler, turnID: turnID) else { - return XCTFail("expected filler lease") - } - XCTAssertTrue(coordinator.releaseOutput(filler)) - guard - case .acquired(let response) = coordinator.acquireOutput( - .selectedVoiceFallback, turnID: turnID) - else { return XCTFail("expected response lease") } - - coordinator.publish( - .playbackFailedScoped( - turnID: turnID, - identity: response.identity, - leaseID: response.id, - message: "playback failed")) - XCTAssertEqual(coordinator.model.lastTerminal?.reason, .playbackFailed) - - let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) - defer { try? FileManager.default.removeItem(at: url) } - let data = try Data(contentsOf: url) - let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) - let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) - XCTAssertEqual(terminal["outcome"] as? String, "failure") - XCTAssertEqual(terminal["response_outcome"] as? String, "failure") - } + func testDeliveredVoiceAnswerDoesNotBecomeResponseFailureWhenJournalFailsLater() throws { + DesktopDiagnosticsManager.shared.resetForTests() + defer { DesktopDiagnosticsManager.shared.resetForTests() } + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) + let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) + coordinator.publish( + .providerResponseStartedScoped( + turnID: turnID, + identity: providerIdentity, + sessionID: nil, + responseID: nil)) + guard + case .acquired(let lease) = coordinator.acquireOutput( + .selectedVoiceFallback, turnID: turnID) + else { return XCTFail("expected output lease") } + let token = try XCTUnwrap(coordinator.nonHubCompletionToken(for: turnID)) + + XCTAssertTrue(coordinator.releaseOutput(lease)) + XCTAssertTrue(coordinator.completeNonHubProvider(token, outcome: .journalFailed)) + XCTAssertEqual(coordinator.model.lastTerminal?.reason, .journalFailed) + + let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) + defer { try? FileManager.default.removeItem(at: url) } + let data = try Data(contentsOf: url) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) + let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) + XCTAssertEqual(terminal["outcome"] as? String, "failure") + XCTAssertEqual(terminal["response_outcome"] as? String, "success") + } - func testNonHubCompletionTokenCannotCloseReplacementTurn() throws { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let oldTurnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: oldTurnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: oldTurnID)) - coordinator.publish(.transcriptionStarted(turnID: oldTurnID)) - coordinator.publish(.transcriptionFinal(turnID: oldTurnID, text: "old")) - let staleToken = try XCTUnwrap(coordinator.nonHubCompletionToken(for: oldTurnID)) - - let newTurnID = coordinator.begin(intent: .hold) - XCTAssertFalse(coordinator.completeNonHubProvider(staleToken, outcome: .journalAccepted)) - XCTAssertEqual(coordinator.activeTurnID, newTurnID) - XCTAssertEqual(coordinator.activeTurn?.phase, .recording) - } + func testFillerDrainDoesNotHideLaterVoicePlaybackFailure() throws { + DesktopDiagnosticsManager.shared.resetForTests() + defer { DesktopDiagnosticsManager.shared.resetForTests() } + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.transcriptionStarted(turnID: turnID)) + coordinator.publish(.transcriptionFinal(turnID: turnID, text: "hello")) + let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) + coordinator.publish( + .providerResponseStartedScoped( + turnID: turnID, + identity: providerIdentity, + sessionID: nil, + responseID: nil)) - func testLateOldTurnGlowClearCannotMutateReplacementResponse() throws { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let barState = FloatingControlBarState() - coordinator.configure(barState: barState) - let oldTurnID = coordinator.begin(intent: .hold) - - let replacementTurnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: replacementTurnID, route: .deepgramBatch)) - coordinator.publish(.finalize(turnID: replacementTurnID)) - coordinator.publish(.transcriptionStarted(turnID: replacementTurnID)) - coordinator.publish(.transcriptionFinal(turnID: replacementTurnID, text: "replacement")) - let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) - coordinator.publish( - .providerResponseStartedScoped( - turnID: replacementTurnID, - identity: providerIdentity, - sessionID: nil, - responseID: nil)) - XCTAssertTrue(barState.isVoiceResponseActive) - - coordinator.publish(.responseActiveChanged(turnID: oldTurnID, active: false)) - - XCTAssertEqual(coordinator.activeTurnID, replacementTurnID) - XCTAssertTrue(barState.isVoiceResponseActive) - XCTAssertEqual(coordinator.model.staleEventCount, 1) - } + guard case .acquired(let filler) = coordinator.acquireOutput(.filler, turnID: turnID) else { + return XCTFail("expected filler lease") + } + XCTAssertTrue(coordinator.releaseOutput(filler)) + guard + case .acquired(let response) = coordinator.acquireOutput( + .selectedVoiceFallback, turnID: turnID) + else { return XCTFail("expected response lease") } - func testPendingHubCommitIsAlreadyOwnedInsteadOfEligibleForBatchFallback() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - coordinator.publish(.finalize(turnID: turnID)) - coordinator.publish(.hubCommitClaimed(turnID: turnID)) - - XCTAssertFalse(coordinator.canCommitHubTurn(turnID)) - XCTAssertTrue( - RealtimeHubCommitOwnershipPolicy.isAlreadyOwned( - turn: coordinator.activeTurn, - requestedTurnID: turnID)) - } + coordinator.publish( + .playbackFailedScoped( + turnID: turnID, + identity: response.identity, + leaseID: response.id, + message: "playback failed")) + XCTAssertEqual(coordinator.model.lastTerminal?.reason, .playbackFailed) + + let url = try XCTUnwrap(DesktopDiagnosticsManager.shared.writeDiagnosticsAttachment()) + defer { try? FileManager.default.removeItem(at: url) } + let data = try Data(contentsOf: url) + let root = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let snapshots = try XCTUnwrap(root["snapshots"] as? [[String: Any]]) + let terminal = try XCTUnwrap(snapshots.first { $0["event"] as? String == "voice_turn_terminal" }) + XCTAssertEqual(terminal["outcome"] as? String, "failure") + XCTAssertEqual(terminal["response_outcome"] as? String, "failure") + } - func testFinalizingHubTurnIsNotClassifiedAsAlreadyOwned() { - let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) - let turnID = coordinator.begin(intent: .hold) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) - coordinator.publish(.finalize(turnID: turnID)) - - XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) - XCTAssertFalse( - RealtimeHubCommitOwnershipPolicy.isAlreadyOwned( - turn: coordinator.activeTurn, - requestedTurnID: turnID)) - } -} + func testNonHubCompletionTokenCannotCloseReplacementTurn() throws { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let oldTurnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: oldTurnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: oldTurnID)) + coordinator.publish(.transcriptionStarted(turnID: oldTurnID)) + coordinator.publish(.transcriptionFinal(turnID: oldTurnID, text: "old")) + let staleToken = try XCTUnwrap(coordinator.nonHubCompletionToken(for: oldTurnID)) + + let newTurnID = coordinator.begin(intent: .hold) + XCTAssertFalse(coordinator.completeNonHubProvider(staleToken, outcome: .journalAccepted)) + XCTAssertEqual(coordinator.activeTurnID, newTurnID) + XCTAssertEqual(coordinator.activeTurn?.phase, .recording) + } -@MainActor -private final class ManualVoiceTurnCancellation: VoiceTurnDeadlineCancellation { - var isCancelled = false + func testLateOldTurnGlowClearCannotMutateReplacementResponse() throws { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let barState = FloatingControlBarState() + coordinator.configure(barState: barState) + let oldTurnID = coordinator.begin(intent: .hold) + + let replacementTurnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: replacementTurnID, route: .deepgramBatch)) + coordinator.publish(.finalize(turnID: replacementTurnID)) + coordinator.publish(.transcriptionStarted(turnID: replacementTurnID)) + coordinator.publish(.transcriptionFinal(turnID: replacementTurnID, text: "replacement")) + let providerIdentity = try XCTUnwrap(coordinator.activeTurn?.providerEffectIdentity) + coordinator.publish( + .providerResponseStartedScoped( + turnID: replacementTurnID, + identity: providerIdentity, + sessionID: nil, + responseID: nil)) + XCTAssertTrue(barState.isVoiceResponseActive) + + coordinator.publish(.responseActiveChanged(turnID: oldTurnID, active: false)) + + XCTAssertEqual(coordinator.activeTurnID, replacementTurnID) + XCTAssertTrue(barState.isVoiceResponseActive) + XCTAssertEqual(coordinator.model.staleEventCount, 1) + } - func cancel() { - isCancelled = true - } -} - -@MainActor -private final class ManualVoiceTurnScheduler: VoiceTurnDeadlineScheduling { - private struct Scheduled { - let deadline: VoiceTurnDeadline - let cancellation: ManualVoiceTurnCancellation - let action: @MainActor () -> Void - } + func testPendingHubCommitIsAlreadyOwnedInsteadOfEligibleForBatchFallback() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) + coordinator.publish(.finalize(turnID: turnID)) + coordinator.publish(.hubCommitClaimed(turnID: turnID)) + + XCTAssertFalse(coordinator.canCommitHubTurn(turnID)) + XCTAssertTrue( + RealtimeHubCommitOwnershipPolicy.isAlreadyOwned( + turn: coordinator.activeTurn, + requestedTurnID: turnID)) + } - private var scheduled: [Scheduled] = [] + func testFinalizingHubTurnIsNotClassifiedAsAlreadyOwned() { + let coordinator = VoiceTurnCoordinator(scheduler: ManualVoiceTurnScheduler()) + let turnID = coordinator.begin(intent: .hold) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: VoiceSessionID()))) + coordinator.publish(.finalize(turnID: turnID)) - var activeCount: Int { - scheduled.filter { !$0.cancellation.isCancelled }.count + XCTAssertTrue(coordinator.canCommitHubTurn(turnID)) + XCTAssertFalse( + RealtimeHubCommitOwnershipPolicy.isAlreadyOwned( + turn: coordinator.activeTurn, + requestedTurnID: turnID)) + } } - func schedule( - deadline: VoiceTurnDeadline, - after interval: TimeInterval, - action: @escaping @MainActor () -> Void - ) - -> VoiceTurnDeadlineCancellation - { - _ = interval - let cancellation = ManualVoiceTurnCancellation() - scheduled.append(.init(deadline: deadline, cancellation: cancellation, action: action)) - return cancellation + @MainActor + private final class ManualVoiceTurnCancellation: VoiceTurnDeadlineCancellation { + var isCancelled = false + + func cancel() { + isCancelled = true + } } - func fire(deadline: VoiceTurnDeadline) { - guard - let index = scheduled.firstIndex(where: { - $0.deadline == deadline && !$0.cancellation.isCancelled - }) - else { return } - let item = scheduled.remove(at: index) - item.action() + @MainActor + private final class ManualVoiceTurnScheduler: VoiceTurnDeadlineScheduling { + private struct Scheduled { + let deadline: VoiceTurnDeadline + let cancellation: ManualVoiceTurnCancellation + let action: @MainActor () -> Void + } + + private var scheduled: [Scheduled] = [] + + var activeCount: Int { + scheduled.filter { !$0.cancellation.isCancelled }.count + } + + func schedule( + deadline: VoiceTurnDeadline, + after interval: TimeInterval, + action: @escaping @MainActor () -> Void + ) + -> VoiceTurnDeadlineCancellation + { + _ = interval + let cancellation = ManualVoiceTurnCancellation() + scheduled.append(.init(deadline: deadline, cancellation: cancellation, action: action)) + return cancellation + } + + func fire(deadline: VoiceTurnDeadline) { + guard + let index = scheduled.firstIndex(where: { + $0.deadline == deadline && !$0.cancellation.isCancelled + }) + else { return } + let item = scheduled.remove(at: index) + item.action() + } } -} +#endif From 4f91bd3847ed82290bfe301f192b91be60b83398 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Wed, 22 Jul 2026 10:43:06 -0400 Subject: [PATCH 04/11] fix(desktop): only ack a voice completion after it reaches the provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review by David (Git-on-my-level) caught a real exactly-once break: AgentCompletionVoiceDelivery advanced the kernel checkpoint whenever sendBackgroundAgentContext returned true, but that path delegated to RealtimeHubSession.sendTextInput, which also returns true when it merely BUFFERS the text (socket not open, or Gemini has no activity window). A completion could be marked seen while only sitting in pendingTextInputs — and stop()/close() clears that buffer, losing the completion permanently while the checkpoint stayed advanced. sendBackgroundAgentContext no longer buffers. It writes to the live provider only when the session can accept it now (isOpen, and for Gemini an open activity window) and returns true; otherwise it returns false without buffering, so the caller leaves the checkpoint unadvanced and retries on the next terminal transition or session connect. This restores the "checkpoint advances only after confirmed delivery" invariant with nothing to lose on close. Bound (not a loss): a completion arriving while a live Gemini session has no open activity window is deferred, not delivered, until the next window/turn or reconnect. It is never acknowledged until actually sent. Tests (RealtimeHubSessionInputLifecycleTests, DEBUG-seam suite): background context before transport ready / open OpenAI / Gemini-without-window each assert the return value and that nothing was buffered (new pendingTextInputCount field on the DEBUG input-lifecycle snapshot). Failure-Class: none --- .../desktop-swift-floatingcontrolbar.json | 4 +-- .../RealtimeHubSession.swift | 27 +++++++++++--- ...ealtimeHubSessionInputLifecycleTests.swift | 36 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json index 6b184c94bf4..031e2c6e6d8 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json @@ -4,14 +4,14 @@ "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2853, "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4842, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1628 + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1647 }, "raise_justifications": { "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": "Agent completion presentation now follows the canonical terminal lifecycle used by PTT and chat.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "Onboarding bar glow, the reach-error card (Retry/Skip), and the notch hover/voice surface-state boundary render on the shared bar surface.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Reach-error state lives with the owning manager; savePreChatCenterIfNeeded snaps to the full stored restore frame so the pill no longer drifts per re-open cycle.", "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "sendBackgroundAgentContext must live beside the file-private sendTextInput buffering path it wraps; the AgentCompletionVoiceDelivery logic itself is a separate file." + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "sendBackgroundAgentContext is a non-buffering delivery variant (returns false instead of buffering) so voice completion acks only after confirmed provider send; it lives beside the sendTextInput path it deliberately diverges from." }, "threshold": 1500 } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift index 31eefd6aac3..00c59cc953a 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift @@ -118,6 +118,7 @@ enum RealtimeHubBargeInStrategy: Equatable { struct RealtimeHubInputLifecycleSnapshot: Equatable { let isOpen: Bool let activityOpen: Bool + let pendingTextInputCount: Int let pendingAudioChunkCount: Int let pendingVideoFrameCount: Int let pendingCommit: Bool @@ -404,6 +405,7 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { returning: RealtimeHubInputLifecycleSnapshot( isOpen: self.isOpen, activityOpen: self.activityOpen, + pendingTextInputCount: self.pendingTextInputs.count, pendingAudioChunkCount: self.pendingAudio.count, pendingVideoFrameCount: self.pendingVideo.count, pendingCommit: self.pendingCommit, @@ -500,11 +502,28 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { await sendTextInput(text, logLabel: "test text input") } - /// Silently appends completed background-agent context to the conversation. - /// No response is requested — the model uses it on its next turn. Buffered - /// like other text input while the socket or Gemini activity window is closed. + /// Silently appends completed background-agent context to the conversation + /// without requesting a response — the model uses it on its next turn. + /// + /// Unlike `sendTextInput`, this deliberately does NOT buffer: it returns + /// `true` only when the text is written to the live provider this call, and + /// `false` when the session cannot accept it right now (socket closed, or + /// Gemini has no open activity window). Buffering would let the completion be + /// dropped by `stop()`/`close()` (which clears `pendingTextInputs`) after the + /// caller already advanced its exactly-once checkpoint. Returning `false` + /// keeps the checkpoint unadvanced so the caller retries on the next terminal + /// transition or session connect. func sendBackgroundAgentContext(_ text: String) async -> Bool { - await sendTextInput(text, logLabel: "background agent completion context") + await withCheckedContinuation { continuation in + q.async { [weak self] in + guard let self, self.isOpen, self.provider == .openai || self.activityOpen else { + continuation.resume(returning: false) + return + } + self.sendTextInputNow(text, logLabel: "background agent completion context") + continuation.resume(returning: true) + } + } } /// A provider can complete a tool-only response after accepting the final tool diff --git a/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift b/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift index 9d7c6d92a37..c187136375a 100644 --- a/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift @@ -291,6 +291,42 @@ import XCTest XCTAssertFalse(committed.pendingCommit) } + func testBackgroundAgentContextDoesNotBufferBeforeTransportIsReady() async { + // Exactly-once contract: a completion must never be marked delivered while + // it is only sitting in an in-memory buffer that stop()/close() would drop. + // sendBackgroundAgentContext must therefore refuse (return false) instead of + // buffering when the session cannot deliver right now. + let delegate = RealtimeHubSessionDelegateSpy() + let session = makeSession(provider: .openai, delegate: delegate) + + let acceptedBeforeReady = await session.sendBackgroundAgentContext("agent finished") + let coldSnapshot = await session.inputLifecycleSnapshot() + XCTAssertFalse(acceptedBeforeReady, "a closed socket must not accept background context") + XCTAssertEqual(coldSnapshot.pendingTextInputCount, 0, "background context must not be buffered") + + session.markReadyForTesting() + let acceptedWhenReady = await session.sendBackgroundAgentContext("agent finished") + let readySnapshot = await session.inputLifecycleSnapshot() + XCTAssertTrue(acceptedWhenReady, "an open OpenAI session accepts background context immediately") + XCTAssertEqual(readySnapshot.pendingTextInputCount, 0, "an accepted send leaves nothing buffered") + } + + func testGeminiBackgroundAgentContextRefusesWithoutAnActivityWindow() async { + // Gemini can only accept text inside an open activity window; without one, + // sendTextInput would buffer. Background context must instead refuse so the + // caller keeps its checkpoint unadvanced and retries when a window opens. + let delegate = RealtimeHubSessionDelegateSpy() + let session = makeSession(provider: .gemini, delegate: delegate) + session.markReadyForTesting() + + let accepted = await session.sendBackgroundAgentContext("agent finished") + let snapshot = await session.inputLifecycleSnapshot() + + XCTAssertFalse(snapshot.activityOpen) + XCTAssertFalse(accepted, "Gemini must refuse background context with no open activity window") + XCTAssertEqual(snapshot.pendingTextInputCount, 0, "refused background context must not be buffered") + } + func testOpenAITransportCloseImmediatelyMakesSessionNonSendableBeforeControllerTeardown() async { let delegate = RealtimeHubSessionDelegateSpy() let session = makeSession(provider: .openai, delegate: delegate) From d63fb3d05bb6375f73de04c0853ead03451be4c5 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Wed, 22 Jul 2026 10:43:06 -0400 Subject: [PATCH 05/11] fix(desktop): keep voice completion service inert until app start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop Swift Test Suites job hung for 6h (killed at the CI cap) once the release-compile gate was fixed and the full suite could finally run. Root cause: the hubDidConnect hook calls AgentCompletionVoiceDelivery.shared .voiceSessionDidConnect(), and the shared instance's default (production) seams spawn a Task into DesktopCoordinatorService.shared -> AgentRuntimeProcess.shared, which tries to launch and waitForInit the real Node agent runtime. RealtimeHub unit tests drive hubDidConnect, so those tests reached into the runtime; with no app bundle/dist present that launch could block indefinitely. The hang was nondeterministic (the first CI run passed step 7, the rebased run hung; the mini's different sandbox never hit it). The service now stays fully inert — no status subscription, no delivery, no runtime reach — until start() runs, which only OmiApp calls at launch. Tests never start it, so voiceSessionDidConnect() is a guaranteed no-op there. Test: voiceSessionDidConnect before start schedules no work and reaches no seam. Failure-Class: none --- .../Chat/AgentCompletionVoiceDelivery.swift | 16 +++++++++++-- .../AgentCompletionVoiceDeliveryTests.swift | 23 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift index 2c16b726ade..f682db17316 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift @@ -41,14 +41,22 @@ final class AgentCompletionVoiceDelivery { private var lastStatusBySurface: [String: AgentRunProjectionStatus] = [:] private var deliveryInFlight = false private var deliveryQueued = false + /// The shared instance stays completely inert — no status subscription, no + /// delivery, no reach into the agent runtime — until `start()` runs at app + /// launch. This keeps `voiceSessionDidConnect()`, which the realtime hub calls + /// from `hubDidConnect` (exercised by RealtimeHub unit tests), a no-op in any + /// context that never started the service. + private var hasStarted: Bool init( isVoiceSessionLive: (@MainActor () -> Bool)? = nil, peekDelta: (@MainActor () async -> Delta?)? = nil, injectContext: (@MainActor (String) async -> Bool)? = nil, acknowledge: (@MainActor (Delta) -> Void)? = nil, - scheduleWork: (@MainActor (@escaping @MainActor () async -> Void) -> Void)? = nil + scheduleWork: (@MainActor (@escaping @MainActor () async -> Void) -> Void)? = nil, + hasStarted: Bool = false ) { + self.hasStarted = hasStarted self.isVoiceSessionLive = isVoiceSessionLive ?? { RealtimeHubController.shared.hasLiveVoiceSession } self.peekDelta = @@ -82,6 +90,7 @@ final class AgentCompletionVoiceDelivery { /// Idempotent; called once from app launch. func start() { guard cancellable == nil else { return } + hasStarted = true cancellable = AgentRuntimeStatusStore.shared.$projectionsBySurface .sink { [weak self] projections in self?.observe(projections) @@ -89,8 +98,11 @@ final class AgentCompletionVoiceDelivery { } /// A newly connected voice session drains completions that finished while no - /// session was live (their checkpoint was deliberately left unadvanced). + /// session was live (their checkpoint was deliberately left unadvanced). Inert + /// until `start()` so the realtime hub's `hubDidConnect` hook never drives the + /// unstarted shared instance into the agent runtime during tests. func voiceSessionDidConnect() { + guard hasStarted else { return } scheduleDelivery() } diff --git a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift index 9fb76069fce..e4e823cc81c 100644 --- a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift +++ b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift @@ -14,12 +14,17 @@ final class AgentCompletionVoiceDeliveryTests: XCTestCase { var delta: AgentCompletionVoiceDelivery.Delta? = AgentCompletionVoiceDelivery.Delta( ids: ["run-1"], prompt: "agent finished", completedAtHighWaterMs: 42) var injectResult = true + let hasStarted: Bool private(set) var peekCount = 0 private(set) var injectedPrompts: [String] = [] private(set) var acknowledged: [[String]] = [] private(set) var scheduled: [@MainActor () async -> Void] = [] + init(hasStarted: Bool = true) { + self.hasStarted = hasStarted + } + lazy var sut = AgentCompletionVoiceDelivery( isVoiceSessionLive: { [unowned self] in self.voiceLive }, peekDelta: { [unowned self] in @@ -35,7 +40,8 @@ final class AgentCompletionVoiceDeliveryTests: XCTestCase { }, scheduleWork: { [unowned self] work in self.scheduled.append(work) - } + }, + hasStarted: hasStarted ) /// Runs every scheduled delivery, including trailing re-runs queued while @@ -158,6 +164,21 @@ final class AgentCompletionVoiceDeliveryTests: XCTestCase { XCTAssertEqual(harness.acknowledged, [["run-1"]]) } + func testVoiceSessionConnectIsInertBeforeStart() async { + // The realtime hub calls voiceSessionDidConnect() from hubDidConnect, which + // RealtimeHub unit tests exercise. Before start() (the app-launch-only wiring) + // it must not schedule work or reach any seam — otherwise the shared instance + // would drive the live agent runtime during those tests. + let harness = Harness(hasStarted: false) + + harness.sut.voiceSessionDidConnect() + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 0) + XCTAssertTrue(harness.scheduled.isEmpty) + XCTAssertTrue(harness.acknowledged.isEmpty) + } + func testTransitionsWhileDeliveryInFlightCoalesceIntoOneTrailingRun() async { let harness = Harness() let first = AgentSurfaceReference.floatingBarRun(runId: "run-1") From 4471936250f3f4c9d1232022e316725a02b7bf6b Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Thu, 23 Jul 2026 16:54:55 -0400 Subject: [PATCH 06/11] fix(desktop): confirm background-context delivery before advancing the checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rebase reintroduced the ack-before-delivery bug: sendBackgroundAgentContext delegated to the buffering sendTextInput, which returns true after *buffering* when the session is warm-idle or the socket is closed. The delivery service acks the exactly-once kernel checkpoint on that true, but stopOnQueue/abandonInputTurn clear pendingTextInputs — so a completion could be acked and then dropped (acked-but-lost). Give background context its own non-buffering path: refuse (return false, leave the checkpoint unadvanced) when the session can't accept context now, and when it can, resume the continuation from the provider send's completion so true means a *confirmed* send, not a fire-and-forget enqueue. Also emit hubDidOpenInputWindow when a warm Gemini activity window opens, so a refused completion can be retried (consumed in the next commit). - canAcceptInjectedContext capability check; shared textInputWire builder - tests: refuse-while-warm-idle -> false; confirmed-send failure -> false (DEBUG seam) --- .../desktop-swift-floatingcontrolbar.json | 4 +- .../RealtimeHubSession.swift | 75 ++++++++++++++----- ...ealtimeHubSessionInputLifecycleTests.swift | 36 +++++++++ 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json index 031e2c6e6d8..ddd6fe54604 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json @@ -4,14 +4,14 @@ "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2853, "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4842, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1647 + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1686 }, "raise_justifications": { "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": "Agent completion presentation now follows the canonical terminal lifecycle used by PTT and chat.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "Onboarding bar glow, the reach-error card (Retry/Skip), and the notch hover/voice surface-state boundary render on the shared bar surface.", "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Reach-error state lives with the owning manager; savePreChatCenterIfNeeded snaps to the full stored restore frame so the pill no longer drifts per re-open cycle.", "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.", - "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "sendBackgroundAgentContext is a non-buffering delivery variant (returns false instead of buffering) so voice completion acks only after confirmed provider send; it lives beside the sendTextInput path it deliberately diverges from." + "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "sendBackgroundAgentContext now confirms the provider send before returning true (exactly-once: no ack before delivery) and the session emits hubDidOpenInputWindow so a warm-idle completion is retried; both must live beside the file-private send/turn machinery." }, "threshold": 1500 } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift index 00c59cc953a..941a7b8b480 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift @@ -65,6 +65,16 @@ protocol RealtimeHubSessionDelegate: AnyObject { identity: RealtimeHubEventIdentity?, source: RealtimeHubSession) func hubDidFinishTurn(identity: RealtimeHubEventIdentity?, source: RealtimeHubSession) func hubDidError(_ message: String, source: RealtimeHubSession) + /// The session became able to accept injected (non-PTT) context — a warm + /// Gemini activity window just opened. The capability signal that retries a + /// background-agent completion left unadvanced while the session was idle. + func hubDidOpenInputWindow(source: RealtimeHubSession) +} + +extension RealtimeHubSessionDelegate { + /// Default no-op: only the controller that owns background-completion delivery + /// needs the "ready for injected context" signal. + func hubDidOpenInputWindow(source: RealtimeHubSession) {} } struct RealtimeHubEventIdentity: Equatable, Sendable { @@ -170,6 +180,13 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { private var testingResponseCreateCount = 0 private var testingLastResponseToolChoice: String? private var testingLastResponseInstruction: String? + /// When set, the testing transport reports this error from `send`, so a + /// confirmed-delivery caller can be tested against a failed provider send. + private var testingForcedSendError: Error? + + func setTestingForcedSendError(_ error: Error?) { + q.async { [weak self] in self?.testingForcedSendError = error } + } #endif private var activeEventIdentity: RealtimeHubEventIdentity? private var completedGeminiEventIdentity: RealtimeHubEventIdentity? @@ -502,26 +519,34 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { await sendTextInput(text, logLabel: "test text input") } - /// Silently appends completed background-agent context to the conversation - /// without requesting a response — the model uses it on its next turn. + /// True when the session can accept injected (non-PTT) context *right now*. + /// Evaluated on `q`. OpenAI accepts on any open socket; Gemini needs an open + /// speech-activity window (opened per turn by `beginInputTurn`). + private var canAcceptInjectedContext: Bool { + isOpen && (provider == .openai || activityOpen) + } + + /// Silently appends completed background-agent context to the conversation. + /// No response is requested — the model uses it on its next turn. /// - /// Unlike `sendTextInput`, this deliberately does NOT buffer: it returns - /// `true` only when the text is written to the live provider this call, and - /// `false` when the session cannot accept it right now (socket closed, or - /// Gemini has no open activity window). Buffering would let the completion be - /// dropped by `stop()`/`close()` (which clears `pendingTextInputs`) after the - /// caller already advanced its exactly-once checkpoint. Returning `false` - /// keeps the checkpoint unadvanced so the caller retries on the next terminal - /// transition or session connect. + /// Unlike ordinary PTT text input this does NOT buffer: the caller advances an + /// exactly-once kernel checkpoint on a `true` return, so `true` must mean + /// "confirmed delivered," never "buffered" (a buffered item is dropped by + /// `stopOnQueue`/`abandonInputTurn` — an acked-but-lost completion). When the + /// session can't accept context yet it returns `false` and the checkpoint + /// stays unadvanced; the delivery service retries when the session next + /// becomes ready (`hubDidOpenInputWindow`). When it can, `true` follows the + /// provider send's completion, not a fire-and-forget enqueue. func sendBackgroundAgentContext(_ text: String) async -> Bool { await withCheckedContinuation { continuation in q.async { [weak self] in - guard let self, self.isOpen, self.provider == .openai || self.activityOpen else { + guard let self, self.canAcceptInjectedContext else { continuation.resume(returning: false) return } - self.sendTextInputNow(text, logLabel: "background agent completion context") - continuation.resume(returning: true) + self.send(json: self.textInputWire(text)) { error in + continuation.resume(returning: error == nil) + } } } } @@ -647,20 +672,27 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { } } - private func sendTextInputNow(_ text: String, logLabel: String) { + /// Per-provider wire form of a user text-input message. Shared by the buffered + /// PTT path (`sendTextInputNow`) and the confirmed, non-buffering background + /// path (`sendBackgroundAgentContext`). + private func textInputWire(_ text: String) -> [String: Any] { switch provider { case .gemini: - send(json: ["realtimeInput": ["text": text]]) + return ["realtimeInput": ["text": text]] case .openai: - send(json: [ + return [ "type": "conversation.item.create", "item": [ "type": "message", "role": "user", "content": [["type": "input_text", "text": text]], ], - ]) + ] } + } + + private func sendTextInputNow(_ text: String, logLabel: String) { + send(json: textInputWire(text)) log("\(tag): \(logLabel) sent (\(text.count) chars)") } @@ -697,6 +729,11 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { self.flushPendingAudioIfReady() self.flushPendingTextInputs() log("\(self.tag): turn begin (activityStart\(interrupting ? ", interrupting in-flight reply" : ""))") + // The activity window is open — the session can now accept injected + // context. Signal delivery so a background completion left unadvanced + // while warm-idle is retried at this natural turn boundary. + let delegate = self.delegate + Task { @MainActor in delegate?.hubDidOpenInputWindow(source: self) } if self.pendingCommit { self.pendingCommit = false self.commitInputTurnNow() @@ -1565,7 +1602,9 @@ final class RealtimeHubSession: NSObject, @unchecked Sendable { testingLastResponseToolChoice = (json["response"] as? [String: Any])?["tool_choice"] as? String testingLastResponseInstruction = (json["response"] as? [String: Any])?["instructions"] as? String } - completion?(nil) + // Lets tests exercise a failed send so callers that gate on delivery + // (sendBackgroundAgentContext → exactly-once checkpoint) can be verified. + completion?(testingForcedSendError) return } #endif diff --git a/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift b/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift index c187136375a..afb33ac75cb 100644 --- a/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/RealtimeHubSessionInputLifecycleTests.swift @@ -403,6 +403,40 @@ import XCTest XCTAssertEqual(completed.inputIdentityCount, 0) } + func testBackgroundAgentContextRefusesGeminiWhileWarmIdleThenSendsWhenWindowOpens() async { + let delegate = RealtimeHubSessionDelegateSpy() + let session = makeSession(provider: .gemini, delegate: delegate) + session.markReadyForTesting() + + // Warm but idle (no activity window): must REFUSE — return false, never + // buffer-and-report-success. A buffered completion is dropped by + // stopOnQueue/abandonInputTurn, so reporting success would advance the + // exactly-once checkpoint on a completion that is then lost. + let refusedWhileIdle = await session.sendBackgroundAgentContext("agent finished") + XCTAssertFalse(refusedWhileIdle) + + // A turn opens the activity window — now the session can accept context. + session.beginInputTurn() + let sentAfterWindow = await session.sendBackgroundAgentContext("agent finished") + XCTAssertTrue(sentAfterWindow) + } + + func testBackgroundAgentContextReturnsFalseWhenTheConfirmedSendFails() async { + let delegate = RealtimeHubSessionDelegateSpy() + let session = makeSession(provider: .openai, delegate: delegate) + session.markReadyForTesting() + + // The checkpoint advances on this `true`, so `true` must mean confirmed + // delivery: a failed provider send must report false, not fire-and-forget. + session.setTestingForcedSendError(RealtimeHubSessionTestError.forced) + let failedSend = await session.sendBackgroundAgentContext("agent finished") + XCTAssertFalse(failedSend) + + session.setTestingForcedSendError(nil) + let confirmedSend = await session.sendBackgroundAgentContext("agent finished") + XCTAssertTrue(confirmedSend) + } + private func makeSession( provider: RealtimeHubProvider, delegate: RealtimeHubSessionDelegate @@ -424,6 +458,8 @@ import XCTest } } + private enum RealtimeHubSessionTestError: Error { case forced } + @MainActor private final class RealtimeHubSessionDelegateSpy: RealtimeHubSessionDelegate { private(set) var connectCount = 0 From 8a0d87d4f322d293829dc1b2a693c912951058ff Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Thu, 23 Jul 2026 16:54:56 -0400 Subject: [PATCH 07/11] fix(desktop): retry a refused voice completion when the input window reopens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendBackgroundAgentContext now refuses (leaves the checkpoint unadvanced) while a warm Gemini session is idle. The only delivery triggers were a terminal transition and session connect, so a completion landing while connected-idle was never retried when the next natural turn opened the activity window. Route the new hubDidOpenInputWindow capability signal to the delivery service so it retries then — the checkpoint still advances only after a confirmed send. - RealtimeHubController forwards hubDidOpenInputWindow -> voiceSessionDidOpenInputWindow - test: connected-idle -> window opens -> retried -> acked exactly once --- .../Chat/AgentCompletionVoiceDelivery.swift | 8 +++++++ ...ealtimeHubController+SessionDelegate.swift | 5 +++++ .../AgentCompletionVoiceDeliveryTests.swift | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift index f682db17316..0a4cca947c3 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift @@ -106,6 +106,14 @@ final class AgentCompletionVoiceDelivery { scheduleDelivery() } + /// A warm session opened an input window (Gemini `activityStart`) and can now + /// accept injected context. Same capability signal as `voiceSessionDidConnect`: + /// retry a completion that `sendBackgroundAgentContext` refused while the + /// session was connected-but-idle (its checkpoint is still unadvanced). + func voiceSessionDidOpenInputWindow() { + scheduleDelivery() + } + func observe(_ projections: [String: AgentRunProjection]) { var fired = false for (key, projection) in projections { diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift index 0cc4d9d62c2..c667b7b55bb 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift @@ -604,6 +604,11 @@ extension RealtimeHubController { authorizedRealtimeToolError(code: AuthorizedToolExecution.Rejection.ownerChangedDuringExecution.code) } + func hubDidOpenInputWindow(source: RealtimeHubSession) { + guard isCurrentSession(source) else { return } + AgentCompletionVoiceDelivery.shared.voiceSessionDidOpenInputWindow() + } + func hubDidConnect(source: RealtimeHubSession) { guard isCurrentSession(source) else { return } lastWarmAt = Date() diff --git a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift index e4e823cc81c..2c93c801b32 100644 --- a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift +++ b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift @@ -101,6 +101,27 @@ final class AgentCompletionVoiceDeliveryTests: XCTestCase { XCTAssertTrue(harness.acknowledged.isEmpty, "checkpoint must not advance on failed delivery") } + func testConnectedIdleGeminiRetriesDeliveryWhenInputWindowOpens() async { + let harness = Harness() + let surface = AgentSurfaceReference.floatingBarRun(runId: "run-1") + + // Completion finishes while the warm session is idle (no activity window): + // sendBackgroundAgentContext refuses, injectContext reports false, and the + // checkpoint stays unadvanced (nothing is buffered or acked). + harness.injectResult = false + harness.sut.observe([surface.key: projection(surface: surface, status: .running)]) + harness.sut.observe([surface.key: projection(surface: surface, status: .succeeded)]) + await harness.drainScheduledWork() + XCTAssertTrue(harness.acknowledged.isEmpty, "a refused completion must not advance the checkpoint") + + // The activity window opens (Gemini beginInputTurn) → capability signal → + // retry; the send now succeeds and the checkpoint advances exactly once. + harness.injectResult = true + harness.sut.voiceSessionDidOpenInputWindow() + await harness.drainScheduledWork() + XCTAssertEqual(harness.acknowledged, [["run-1"]], "window-open retry delivers and acks exactly once") + } + func testNoLiveVoiceSessionDoesNotPeekOrAcknowledge() async { let harness = Harness() harness.voiceLive = false From 3c7fad589bff1964a22da3d910554e3ba4224cd6 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Thu, 23 Jul 2026 18:40:33 -0400 Subject: [PATCH 08/11] fix(desktop): keep input-window completion retry inert until service start voiceSessionDidOpenInputWindow() reached scheduleDelivery() without the hasStarted guard that voiceSessionDidConnect() has. beginInputTurn emits hubDidOpenInputWindow, which RealtimeHub unit tests exercise, so before start() the shared delivery singleton drove the live agent runtime mid-test and the Desktop Swift Test Suites job hung (reintroducing the regression 9befcb8 fixed for voiceSessionDidConnect). Guard it the same way and add an inert-before-start regression test. --- .../Chat/AgentCompletionVoiceDelivery.swift | 1 + .../Tests/AgentCompletionVoiceDeliveryTests.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift index 0a4cca947c3..38d38123fc4 100644 --- a/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift +++ b/desktop/macos/Desktop/Sources/Chat/AgentCompletionVoiceDelivery.swift @@ -111,6 +111,7 @@ final class AgentCompletionVoiceDelivery { /// retry a completion that `sendBackgroundAgentContext` refused while the /// session was connected-but-idle (its checkpoint is still unadvanced). func voiceSessionDidOpenInputWindow() { + guard hasStarted else { return } scheduleDelivery() } diff --git a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift index 2c93c801b32..6d84377d09a 100644 --- a/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift +++ b/desktop/macos/Desktop/Tests/AgentCompletionVoiceDeliveryTests.swift @@ -200,6 +200,21 @@ final class AgentCompletionVoiceDeliveryTests: XCTestCase { XCTAssertTrue(harness.acknowledged.isEmpty) } + func testVoiceSessionOpenInputWindowIsInertBeforeStart() async { + // beginInputTurn emits hubDidOpenInputWindow, which RealtimeHub unit tests + // exercise. Like voiceSessionDidConnect(), the input-window retry must stay + // inert before start() — otherwise the shared instance reaches the live + // agent runtime mid-test and the suite hangs. + let harness = Harness(hasStarted: false) + + harness.sut.voiceSessionDidOpenInputWindow() + await harness.drainScheduledWork() + + XCTAssertEqual(harness.peekCount, 0) + XCTAssertTrue(harness.scheduled.isEmpty) + XCTAssertTrue(harness.acknowledged.isEmpty) + } + func testTransitionsWhileDeliveryInFlightCoalesceIntoOneTrailingRun() async { let harness = Harness() let first = AgentSurfaceReference.floatingBarRun(runId: "run-1") From 7348c68be8ac4cc23954026a94b4287bd1783e9b Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Thu, 23 Jul 2026 21:13:24 -0400 Subject: [PATCH 09/11] fix(desktop): make SingleFlight test Gate an actor for strict concurrency RealtimeVoiceContextSingleFlightTests constructed a nonisolated Gate class inside @MainActor methods and sent it into the nonisolated single-flight closures, which fails the Swift 6 sending/data-race check on the current toolchain (Desktop Swift Test Suites could not compile the test target). Making Gate an actor makes it Sendable and serializes its state, also closing the latent startCount/continuation race the class form had. Verified on macOS: test target builds clean and the suite passes 3/3. --- ...ealtimeVoiceContextSingleFlightTests.swift | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/desktop/macos/Desktop/Tests/RealtimeVoiceContextSingleFlightTests.swift b/desktop/macos/Desktop/Tests/RealtimeVoiceContextSingleFlightTests.swift index d3e3673b8f6..330a1f3ed0d 100644 --- a/desktop/macos/Desktop/Tests/RealtimeVoiceContextSingleFlightTests.swift +++ b/desktop/macos/Desktop/Tests/RealtimeVoiceContextSingleFlightTests.swift @@ -4,7 +4,10 @@ import XCTest @MainActor final class RealtimeVoiceContextSingleFlightTests: XCTestCase { - private final class Gate { + // An actor (Sendable) so it can cross into the nonisolated single-flight + // closures without tripping Swift's sending/data-race checks; serialized + // access also closes the startCount/continuation race the class form had. + private actor Gate { var startCount = 0 var continuation: CheckedContinuation? @@ -22,7 +25,8 @@ final class RealtimeVoiceContextSingleFlightTests: XCTestCase { } private func waitUntilStarted(_ gate: Gate) async { - for _ in 0..<100 where gate.startCount == 0 { + for _ in 0..<100 { + if await gate.startCount != 0 { break } await Task.yield() } } @@ -35,11 +39,12 @@ final class RealtimeVoiceContextSingleFlightTests: XCTestCase { let turnWaiter = Task { await singleFlight.joinOrStart { await gate.run() }.value } await waitUntilStarted(gate) - XCTAssertEqual(gate.startCount, 1) + let startedCount = await gate.startCount + XCTAssertEqual(startedCount, 1) XCTAssertTrue(singleFlight.isRunning) turnWaiter.cancel() - gate.finish(true) + await gate.finish(true) let speculativeResult = await speculative.value let turnWaiterResult = await turnWaiter.value @@ -47,7 +52,8 @@ final class RealtimeVoiceContextSingleFlightTests: XCTestCase { XCTAssertTrue(turnWaiterResult) await Task.yield() XCTAssertFalse(singleFlight.isRunning) - XCTAssertEqual(gate.startCount, 1) + let finalCount = await gate.startCount + XCTAssertEqual(finalCount, 1) } func testForcedRefreshDoesNotReuseAnOlderSpeculativeRead() async { @@ -60,12 +66,14 @@ final class RealtimeVoiceContextSingleFlightTests: XCTestCase { let forced = singleFlight.restart { await forcedGate.run() } await waitUntilStarted(forcedGate) - XCTAssertEqual(speculativeGate.startCount, 1) - XCTAssertEqual(forcedGate.startCount, 1) + let speculativeStarted = await speculativeGate.startCount + let forcedStarted = await forcedGate.startCount + XCTAssertEqual(speculativeStarted, 1) + XCTAssertEqual(forcedStarted, 1) XCTAssertTrue(singleFlight.isRunning) - speculativeGate.finish(false) - forcedGate.finish(true) + await speculativeGate.finish(false) + await forcedGate.finish(true) let speculativeResult = await speculative.value let forcedResult = await forced.value @@ -81,15 +89,16 @@ final class RealtimeVoiceContextSingleFlightTests: XCTestCase { let failed = singleFlight.joinOrStart { await failedGate.run() } await waitUntilStarted(failedGate) - failedGate.finish(false) + await failedGate.finish(false) let failedResult = await failed.value XCTAssertFalse(failedResult) XCTAssertFalse(singleFlight.isRunning) let retry = singleFlight.joinOrStart { await retryGate.run() } await waitUntilStarted(retryGate) - XCTAssertEqual(retryGate.startCount, 1) - retryGate.finish(true) + let retryStarted = await retryGate.startCount + XCTAssertEqual(retryStarted, 1) + await retryGate.finish(true) let retryResult = await retry.value XCTAssertTrue(retryResult) } From baa9c0897b39436c96b37d2d2175e420047d29d2 Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Fri, 24 Jul 2026 22:08:37 -0400 Subject: [PATCH 10/11] style(desktop): drop stray blank line from merge resolution The origin/main merge resolution in AgentSyncBatchQueryTests.swift left one extra blank line, which desktop-swift-format-lint flags as format drift (the check enforces formatter idempotency over the whole Swift scope). Verified: desktop/macos/scripts/swift-format-wrapper.sh lint-scope reports 'clean (no formatting drift in scope)'. --- desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift b/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift index b7b582b7d0d..9bb8a1ea170 100644 --- a/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift +++ b/desktop/macos/Desktop/Tests/AgentSyncBatchQueryTests.swift @@ -364,7 +364,6 @@ final class AgentSyncBatchQueryTests: XCTestCase { private var storageFixture: RewindStorageTestIsolation.Fixture? private var authSnapshot: RewindStorageTestIsolation.AuthSnapshot? - override func setUp() async throws { try await super.setUp() let fixture = try await RewindStorageTestIsolation.setUp(userIdPrefix: "agent-sync-recovery") From 60fa15a57f5dfef0d1fb3947b2b60d183c4e88af Mon Sep 17 00:00:00 2001 From: skanderkaroui Date: Fri, 24 Jul 2026 22:43:29 -0400 Subject: [PATCH 11/11] test(desktop): gate DEBUG-seam handoff policy tests for release compiles RealtimeHubSessionHandoffPolicyTests drives DEBUG-only seams on RealtimeHubController (testingWarmAfterDrain, testingSessionStartAfterDrain) and DesktopDiagnosticsManager (resetForTests), but was not #if DEBUG gated. `swift test -c release --filter ...` still compiles the whole test target, so the release-mode notification regression step failed to build with 'value of type RealtimeHubController has no member testingWarmAfterDrain'. That step is path-gated on should_notification_release_regression, so main rarely runs it and the gap stayed latent; this branch's diff triggers it. Same fix already applied in this PR to the VoiceTurnCoordinator and AgentSync recovery suites, and matching the omi-release-compile convention. Verified: swift-format lint-scope clean; DEBUG run still discovers the suite (21 tests, 0 failures). --- ...RealtimeHubSessionHandoffPolicyTests.swift | 935 +++++++++--------- 1 file changed, 471 insertions(+), 464 deletions(-) diff --git a/desktop/macos/Desktop/Tests/RealtimeHubSessionHandoffPolicyTests.swift b/desktop/macos/Desktop/Tests/RealtimeHubSessionHandoffPolicyTests.swift index 2187c5d5671..06e579b1cb3 100644 --- a/desktop/macos/Desktop/Tests/RealtimeHubSessionHandoffPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/RealtimeHubSessionHandoffPolicyTests.swift @@ -3,518 +3,525 @@ import XCTest @testable import Omi_Computer -final class RealtimeHubSessionHandoffPolicyTests: XCTestCase { - @MainActor - func testPhysicalReplacementGateDrainsBeforeStartingAndCoalescesDuplicates() async { - let gate = RealtimeHubTransportReplacementGate() - let stopEntered = expectation(description: "old transport stop entered") - let replacementStarted = expectation(description: "replacement started") - var releaseStop: CheckedContinuation? - var events: [String] = [] - - XCTAssertTrue( - gate.replace( - stop: { - events.append("stop") - await withCheckedContinuation { - releaseStop = $0 - stopEntered.fulfill() - } - events.append("drained") - }, - start: { - events.append("start") - replacementStarted.fulfill() - })) - await fulfillment(of: [stopEntered], timeout: 1) - - XCTAssertFalse( - gate.replace( - stop: { XCTFail("coalesced replacement must not stop twice") }, - start: { XCTFail("coalesced replacement must not start twice") })) - XCTAssertEqual(events, ["stop"]) - - releaseStop?.resume() - await fulfillment(of: [replacementStarted], timeout: 1) - XCTAssertEqual(events, ["stop", "drained", "start"]) - XCTAssertFalse(gate.isPending) - } - - @MainActor - func testPhysicalReplacementGateCancellationStillWaitsForDrainAndNeverStarts() async { - let gate = RealtimeHubTransportReplacementGate() - let stopEntered = expectation(description: "stop entered") - let gateBecameIdle = expectation(description: "gate became idle") - var releaseStop: CheckedContinuation? - var startCount = 0 - - XCTAssertTrue( - gate.replace( - stop: { - await withCheckedContinuation { - releaseStop = $0 - stopEntered.fulfill() - } - }, - start: { startCount += 1 })) - await fulfillment(of: [stopEntered], timeout: 1) - Task { - await gate.waitUntilIdle() - gateBecameIdle.fulfill() +#if DEBUG + // omi-release-compile: this suite drives RealtimeHubController and + // DesktopDiagnosticsManager DEBUG-only test seams (testingWarmAfterDrain, + // testingSessionStartAfterDrain, resetForTests); the release-mode + // notification regression step must compile the bundle without them. + + final class RealtimeHubSessionHandoffPolicyTests: XCTestCase { + @MainActor + func testPhysicalReplacementGateDrainsBeforeStartingAndCoalescesDuplicates() async { + let gate = RealtimeHubTransportReplacementGate() + let stopEntered = expectation(description: "old transport stop entered") + let replacementStarted = expectation(description: "replacement started") + var releaseStop: CheckedContinuation? + var events: [String] = [] + + XCTAssertTrue( + gate.replace( + stop: { + events.append("stop") + await withCheckedContinuation { + releaseStop = $0 + stopEntered.fulfill() + } + events.append("drained") + }, + start: { + events.append("start") + replacementStarted.fulfill() + })) + await fulfillment(of: [stopEntered], timeout: 1) + + XCTAssertFalse( + gate.replace( + stop: { XCTFail("coalesced replacement must not stop twice") }, + start: { XCTFail("coalesced replacement must not start twice") })) + XCTAssertEqual(events, ["stop"]) + + releaseStop?.resume() + await fulfillment(of: [replacementStarted], timeout: 1) + XCTAssertEqual(events, ["stop", "drained", "start"]) + XCTAssertFalse(gate.isPending) } - gate.cancel() - XCTAssertTrue(gate.isPending, "cancellation cannot advertise idle before physical drain") - XCTAssertEqual(startCount, 0) - releaseStop?.resume() - await fulfillment(of: [gateBecameIdle], timeout: 1) - XCTAssertFalse(gate.isPending) - XCTAssertEqual(startCount, 0) - } - - @MainActor - func testCancelTurnWaitsForTransportAcknowledgementBeforeControllerRewarm() async throws { - let controller = RealtimeHubController() - let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) - let rewarmed = expectation(description: "controller rewarmed") - controller.testingWarmAfterDrain = { rewarmed.fulfill() } - let coordinator = VoiceTurnCoordinator.shared - coordinator.reset() - let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) - - XCTAssertTrue(controller.cancelTurn(turnID: turnID)) - await Task.yield() - XCTAssertTrue(controller.sessionReplacementGate.isPending) - XCTAssertEqual(fixture.tracker.liveCount, 1) - - fixture.transport.acknowledgeClose() - await fulfillment(of: [rewarmed], timeout: 1) - XCTAssertEqual(fixture.tracker.liveCount, 0) - XCTAssertFalse(controller.sessionReplacementGate.isPending) - coordinator.reset() - } + @MainActor + func testPhysicalReplacementGateCancellationStillWaitsForDrainAndNeverStarts() async { + let gate = RealtimeHubTransportReplacementGate() + let stopEntered = expectation(description: "stop entered") + let gateBecameIdle = expectation(description: "gate became idle") + var releaseStop: CheckedContinuation? + var startCount = 0 + + XCTAssertTrue( + gate.replace( + stop: { + await withCheckedContinuation { + releaseStop = $0 + stopEntered.fulfill() + } + }, + start: { startCount += 1 })) + await fulfillment(of: [stopEntered], timeout: 1) + Task { + await gate.waitUntilIdle() + gateBecameIdle.fulfill() + } - @MainActor - func testStaleOwnerReadinessWaitsForTransportAcknowledgementBeforeRewarm() async throws { - let controller = RealtimeHubController() - let staleOwner = RealtimeHubOwnerScope.authenticated("stale-\(UUID().uuidString)") - let fixture = try await installDelayedTransport(on: controller, ownerScope: staleOwner) - let rewarmed = expectation(description: "controller rewarmed") - controller.testingWarmAfterDrain = { rewarmed.fulfill() } - - XCTAssertFalse(controller.isTransportReady) - await Task.yield() - XCTAssertTrue(controller.sessionReplacementGate.isPending) - XCTAssertEqual(fixture.tracker.liveCount, 1) - - fixture.transport.acknowledgeClose() - await fulfillment(of: [rewarmed], timeout: 1) - XCTAssertEqual(fixture.tracker.liveCount, 0) - XCTAssertNil(controller.session) - } + gate.cancel() + XCTAssertTrue(gate.isPending, "cancellation cannot advertise idle before physical drain") + XCTAssertEqual(startCount, 0) + releaseStop?.resume() + await fulfillment(of: [gateBecameIdle], timeout: 1) + XCTAssertFalse(gate.isPending) + XCTAssertEqual(startCount, 0) + } - @MainActor - func testProviderFailoverPreservesChoiceButWaitsForOldTransportAcknowledgement() async throws { - let controller = RealtimeHubController() - let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) - let rewarmed = expectation(description: "alternate provider rewarmed") - controller.testingWarmAfterDrain = { rewarmed.fulfill() } - - XCTAssertTrue(controller.failoverToAlternateProvider(reason: "other")) - XCTAssertEqual(controller.fallbackProvider, RealtimeHubSettings.shared.provider.alternate) - await Task.yield() - XCTAssertEqual(fixture.tracker.liveCount, 1) - XCTAssertTrue(controller.sessionReplacementGate.isPending) - - fixture.transport.acknowledgeClose() - await fulfillment(of: [rewarmed], timeout: 1) - XCTAssertEqual(fixture.tracker.liveCount, 0) - XCTAssertNil(controller.session) - } + @MainActor + func testCancelTurnWaitsForTransportAcknowledgementBeforeControllerRewarm() async throws { + let controller = RealtimeHubController() + let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) + let rewarmed = expectation(description: "controller rewarmed") + controller.testingWarmAfterDrain = { rewarmed.fulfill() } + let coordinator = VoiceTurnCoordinator.shared + coordinator.reset() + let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) + + XCTAssertTrue(controller.cancelTurn(turnID: turnID)) + await Task.yield() + XCTAssertTrue(controller.sessionReplacementGate.isPending) + XCTAssertEqual(fixture.tracker.liveCount, 1) + + fixture.transport.acknowledgeClose() + await fulfillment(of: [rewarmed], timeout: 1) + XCTAssertEqual(fixture.tracker.liveCount, 0) + XCTAssertFalse(controller.sessionReplacementGate.isPending) + coordinator.reset() + } - @MainActor - func testEnsureWarmProviderMismatchWaitsForTransportAcknowledgementBeforeRewarm() async throws { - let controller = RealtimeHubController() - let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) - let rewarmed = expectation(description: "mismatched provider rewarmed") - controller.testingWarmAfterDrain = { rewarmed.fulfill() } - controller.fallbackProvider = .openai - - controller.ensureWarm() - await Task.yield() - - XCTAssertTrue(controller.sessionReplacementGate.isPending) - XCTAssertEqual(fixture.tracker.liveCount, 1) - XCTAssertNil(controller.session) - - fixture.transport.acknowledgeClose() - await fulfillment(of: [rewarmed], timeout: 1) - XCTAssertEqual(fixture.tracker.liveCount, 0) - XCTAssertFalse(controller.sessionReplacementGate.isPending) - } + @MainActor + func testStaleOwnerReadinessWaitsForTransportAcknowledgementBeforeRewarm() async throws { + let controller = RealtimeHubController() + let staleOwner = RealtimeHubOwnerScope.authenticated("stale-\(UUID().uuidString)") + let fixture = try await installDelayedTransport(on: controller, ownerScope: staleOwner) + let rewarmed = expectation(description: "controller rewarmed") + controller.testingWarmAfterDrain = { rewarmed.fulfill() } + + XCTAssertFalse(controller.isTransportReady) + await Task.yield() + XCTAssertTrue(controller.sessionReplacementGate.isPending) + XCTAssertEqual(fixture.tracker.liveCount, 1) + + fixture.transport.acknowledgeClose() + await fulfillment(of: [rewarmed], timeout: 1) + XCTAssertEqual(fixture.tracker.liveCount, 0) + XCTAssertNil(controller.session) + } - @MainActor - func testBargeInFailoverWaitsForTransportAcknowledgementBeforeSpecializedStart() async throws { - let defaults = UserDefaults.standard - let keyName = BYOKProvider.openai.storageKey - let previousKey = defaults.object(forKey: keyName) - defaults.set("barge-in-fixture", forKey: keyName) - defer { - if let previousKey { - defaults.set(previousKey, forKey: keyName) - } else { - defaults.removeObject(forKey: keyName) - } + @MainActor + func testProviderFailoverPreservesChoiceButWaitsForOldTransportAcknowledgement() async throws { + let controller = RealtimeHubController() + let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) + let rewarmed = expectation(description: "alternate provider rewarmed") + controller.testingWarmAfterDrain = { rewarmed.fulfill() } + + XCTAssertTrue(controller.failoverToAlternateProvider(reason: "other")) + XCTAssertEqual(controller.fallbackProvider, RealtimeHubSettings.shared.provider.alternate) + await Task.yield() + XCTAssertEqual(fixture.tracker.liveCount, 1) + XCTAssertTrue(controller.sessionReplacementGate.isPending) + + fixture.transport.acknowledgeClose() + await fulfillment(of: [rewarmed], timeout: 1) + XCTAssertEqual(fixture.tracker.liveCount, 0) + XCTAssertNil(controller.session) } - let controller = RealtimeHubController() - let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) - controller.prefetchedVoiceContextOwnerScope = .signedOut - controller.prefetchedVoiceContextSessionID = "fixture-session" - controller.prefetchedVoiceContextFreshnessIdentity = "fixture-freshness" - let turnID = VoiceTurnID() - let responseID = VoiceResponseID("barge-in-response") - controller.replacementAudioBuffer = RealtimeReplacementAudioBuffer( - turnID: turnID, - responseID: responseID, - identity: VoiceEffectIdentity(turnID: turnID, effectID: 1)) - controller.voiceResponseID = responseID - controller.pendingBargeInOwnerScope = .signedOut - let specializedStart = expectation(description: "specialized replacement started") - var specializedStartCount = 0 - controller.testingSessionStartAfterDrain = { provider, auth, ownerScope in - specializedStartCount += 1 - XCTAssertEqual(provider, .openai) - XCTAssertEqual(ownerScope, .signedOut) - XCTAssertFalse(auth.isEphemeral) - specializedStart.fulfill() - return true + @MainActor + func testEnsureWarmProviderMismatchWaitsForTransportAcknowledgementBeforeRewarm() async throws { + let controller = RealtimeHubController() + let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) + let rewarmed = expectation(description: "mismatched provider rewarmed") + controller.testingWarmAfterDrain = { rewarmed.fulfill() } + controller.fallbackProvider = .openai + + controller.ensureWarm() + await Task.yield() + + XCTAssertTrue(controller.sessionReplacementGate.isPending) + XCTAssertEqual(fixture.tracker.liveCount, 1) + XCTAssertNil(controller.session) + + fixture.transport.acknowledgeClose() + await fulfillment(of: [rewarmed], timeout: 1) + XCTAssertEqual(fixture.tracker.liveCount, 0) + XCTAssertFalse(controller.sessionReplacementGate.isPending) } - XCTAssertTrue(controller.failoverBargeInReplacement(from: .gemini, reason: "fixture")) - await Task.yield() + @MainActor + func testBargeInFailoverWaitsForTransportAcknowledgementBeforeSpecializedStart() async throws { + let defaults = UserDefaults.standard + let keyName = BYOKProvider.openai.storageKey + let previousKey = defaults.object(forKey: keyName) + defaults.set("barge-in-fixture", forKey: keyName) + defer { + if let previousKey { + defaults.set(previousKey, forKey: keyName) + } else { + defaults.removeObject(forKey: keyName) + } + } + + let controller = RealtimeHubController() + let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) + controller.prefetchedVoiceContextOwnerScope = .signedOut + controller.prefetchedVoiceContextSessionID = "fixture-session" + controller.prefetchedVoiceContextFreshnessIdentity = "fixture-freshness" + let turnID = VoiceTurnID() + let responseID = VoiceResponseID("barge-in-response") + controller.replacementAudioBuffer = RealtimeReplacementAudioBuffer( + turnID: turnID, + responseID: responseID, + identity: VoiceEffectIdentity(turnID: turnID, effectID: 1)) + controller.voiceResponseID = responseID + controller.pendingBargeInOwnerScope = .signedOut + let specializedStart = expectation(description: "specialized replacement started") + var specializedStartCount = 0 + controller.testingSessionStartAfterDrain = { provider, auth, ownerScope in + specializedStartCount += 1 + XCTAssertEqual(provider, .openai) + XCTAssertEqual(ownerScope, .signedOut) + XCTAssertFalse(auth.isEphemeral) + specializedStart.fulfill() + return true + } - XCTAssertTrue(controller.sessionReplacementGate.isPending) - XCTAssertEqual(fixture.tracker.liveCount, 1) - XCTAssertEqual(specializedStartCount, 0) - XCTAssertNotNil(controller.replacementAudioBuffer) + XCTAssertTrue(controller.failoverBargeInReplacement(from: .gemini, reason: "fixture")) + await Task.yield() - fixture.transport.acknowledgeClose() - await fulfillment(of: [specializedStart], timeout: 1) - XCTAssertEqual(fixture.tracker.liveCount, 0) - XCTAssertEqual(specializedStartCount, 1) - XCTAssertNotNil(controller.replacementAudioBuffer) - } + XCTAssertTrue(controller.sessionReplacementGate.isPending) + XCTAssertEqual(fixture.tracker.liveCount, 1) + XCTAssertEqual(specializedStartCount, 0) + XCTAssertNotNil(controller.replacementAudioBuffer) - @MainActor - func testBargeInFailoverExhaustedWhenAlternateProviderAlreadyActive() { - DesktopDiagnosticsManager.shared.resetForTests() - defer { DesktopDiagnosticsManager.shared.resetForTests() } - - let controller = RealtimeHubController() - let turnID = VoiceTurnID() - let responseID = VoiceResponseID("barge-in-response") - controller.replacementAudioBuffer = RealtimeReplacementAudioBuffer( - turnID: turnID, - responseID: responseID, - identity: VoiceEffectIdentity(turnID: turnID, effectID: 1)) - controller.voiceResponseID = responseID - controller.pendingBargeInOwnerScope = .signedOut - controller.fallbackProvider = .openai - - XCTAssertFalse( - controller.failoverBargeInReplacement( - from: .openai, - reason: "quota", - mintAttemptId: "mint-42")) - - let snapshot = DesktopDiagnosticsManager.shared.currentSnapshotsForSentry().last - XCTAssertEqual(snapshot?["event"] as? String, "fallback_triggered") - XCTAssertEqual(snapshot?["area"] as? String, "realtime_hub") - XCTAssertEqual(snapshot?["from"] as? String, "openai") - XCTAssertEqual(snapshot?["to"] as? String, "cascade") - XCTAssertEqual(snapshot?["outcome"] as? String, "exhausted") - XCTAssertEqual(snapshot?["mint_attempt_id"] as? String, "mint-42") - } + fixture.transport.acknowledgeClose() + await fulfillment(of: [specializedStart], timeout: 1) + XCTAssertEqual(fixture.tracker.liveCount, 0) + XCTAssertEqual(specializedStartCount, 1) + XCTAssertNotNil(controller.replacementAudioBuffer) + } - @MainActor - func testDuplicateTransportTerminalCallbacksAndLateStaleCallbackFinishReducerOnce() async throws { - let controller = RealtimeHubController() - let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) - let coordinator = VoiceTurnCoordinator.shared - coordinator.reset() - defer { coordinator.reset() } - let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) - let sessionID = try XCTUnwrap(controller.voiceSessionID) - coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: sessionID))) - let terminalized = expectation(description: "reducer terminalized") - var observedTerminal = false - let observation = coordinator.observeSnapshots { model in - guard model.turn?.id == turnID, model.turn?.phase.isTerminal == true, - !observedTerminal - else { return } - observedTerminal = true - terminalized.fulfill() + @MainActor + func testBargeInFailoverExhaustedWhenAlternateProviderAlreadyActive() { + DesktopDiagnosticsManager.shared.resetForTests() + defer { DesktopDiagnosticsManager.shared.resetForTests() } + + let controller = RealtimeHubController() + let turnID = VoiceTurnID() + let responseID = VoiceResponseID("barge-in-response") + controller.replacementAudioBuffer = RealtimeReplacementAudioBuffer( + turnID: turnID, + responseID: responseID, + identity: VoiceEffectIdentity(turnID: turnID, effectID: 1)) + controller.voiceResponseID = responseID + controller.pendingBargeInOwnerScope = .signedOut + controller.fallbackProvider = .openai + + XCTAssertFalse( + controller.failoverBargeInReplacement( + from: .openai, + reason: "quota", + mintAttemptId: "mint-42")) + + let snapshot = DesktopDiagnosticsManager.shared.currentSnapshotsForSentry().last + XCTAssertEqual(snapshot?["event"] as? String, "fallback_triggered") + XCTAssertEqual(snapshot?["area"] as? String, "realtime_hub") + XCTAssertEqual(snapshot?["from"] as? String, "openai") + XCTAssertEqual(snapshot?["to"] as? String, "cascade") + XCTAssertEqual(snapshot?["outcome"] as? String, "exhausted") + XCTAssertEqual(snapshot?["mint_attempt_id"] as? String, "mint-42") } - defer { observation.cancel() } - fixture.transport.emitErrorCloseAndDuplicateError() - await fulfillment(of: [terminalized], timeout: 1) - await Task.yield() + @MainActor + func testDuplicateTransportTerminalCallbacksAndLateStaleCallbackFinishReducerOnce() async throws { + let controller = RealtimeHubController() + let fixture = try await installDelayedTransport(on: controller, ownerScope: .signedOut) + let coordinator = VoiceTurnCoordinator.shared + coordinator.reset() + defer { coordinator.reset() } + let turnID = RealtimeAutomationTurnHarness.begin(on: coordinator) + let sessionID = try XCTUnwrap(controller.voiceSessionID) + coordinator.publish(.selectRoute(turnID: turnID, route: .hub(sessionID: sessionID))) + let terminalized = expectation(description: "reducer terminalized") + var observedTerminal = false + let observation = coordinator.observeSnapshots { model in + guard model.turn?.id == turnID, model.turn?.phase.isTerminal == true, + !observedTerminal + else { return } + observedTerminal = true + terminalized.fulfill() + } + defer { observation.cancel() } + + fixture.transport.emitErrorCloseAndDuplicateError() + await fulfillment(of: [terminalized], timeout: 1) + await Task.yield() + + XCTAssertNil(controller.session, "the first terminal callback must fence the source immediately") + fixture.transport.emitErrorCloseAndDuplicateError() + await Task.yield() + let terminals = coordinator.timelineSnapshot().filter { + $0.turnID == turnID && $0.terminalReason != nil + } + XCTAssertEqual(terminals.count, 1) + XCTAssertEqual(coordinator.model.turn?.phase, .terminal(.providerFailed)) - XCTAssertNil(controller.session, "the first terminal callback must fence the source immediately") - fixture.transport.emitErrorCloseAndDuplicateError() - await Task.yield() - let terminals = coordinator.timelineSnapshot().filter { - $0.turnID == turnID && $0.terminalReason != nil + controller.sessionReplacementGate.cancel() + fixture.transport.acknowledgeClose() + await controller.sessionReplacementGate.waitUntilIdle() } - XCTAssertEqual(terminals.count, 1) - XCTAssertEqual(coordinator.model.turn?.phase, .terminal(.providerFailed)) - controller.sessionReplacementGate.cancel() - fixture.transport.acknowledgeClose() - await controller.sessionReplacementGate.waitUntilIdle() - } + func testProviderLogTagDoesNotGuessOpenAIWhileSessionIsUnbound() { + XCTAssertEqual(RealtimeHubProviderLogTag.current(nil), "unbound") + XCTAssertEqual(RealtimeHubProviderLogTag.current(.gemini), "gemini") + XCTAssertEqual(RealtimeHubProviderLogTag.current(.openai), "openai") + } - func testProviderLogTagDoesNotGuessOpenAIWhileSessionIsUnbound() { - XCTAssertEqual(RealtimeHubProviderLogTag.current(nil), "unbound") - XCTAssertEqual(RealtimeHubProviderLogTag.current(.gemini), "gemini") - XCTAssertEqual(RealtimeHubProviderLogTag.current(.openai), "openai") - } + func testAuthenticatedSocketWithStaleContextCapturesAndBuffersInsteadOfEnteringDirectly() { + XCTAssertEqual( + RealtimePTTAdmissionPolicy.decide( + requirementIsResolved: true, + transportIsReady: true, + bindingMatchesRequirement: false), + .captureAndBuffer) + } - func testAuthenticatedSocketWithStaleContextCapturesAndBuffersInsteadOfEnteringDirectly() { - XCTAssertEqual( - RealtimePTTAdmissionPolicy.decide( - requirementIsResolved: true, - transportIsReady: true, - bindingMatchesRequirement: false), - .captureAndBuffer) - } + func testOnlyExactAuthenticatedBindingAdmitsPTTImmediately() { + XCTAssertEqual( + RealtimePTTAdmissionPolicy.decide( + requirementIsResolved: true, + transportIsReady: true, + bindingMatchesRequirement: true), + .immediate) + } - func testOnlyExactAuthenticatedBindingAdmitsPTTImmediately() { - XCTAssertEqual( - RealtimePTTAdmissionPolicy.decide( - requirementIsResolved: true, - transportIsReady: true, - bindingMatchesRequirement: true), - .immediate) - } + func testMatchingBindingNeverStartsMaintenanceHandoff() { + XCTAssertEqual( + RealtimeHubSessionHandoffPolicy.decide( + bindingMatchesRequirement: true, + canReplaceIdleSession: true, + hasBufferedTurn: false), + .keepActive) + } - func testMatchingBindingNeverStartsMaintenanceHandoff() { - XCTAssertEqual( - RealtimeHubSessionHandoffPolicy.decide( - bindingMatchesRequirement: true, - canReplaceIdleSession: true, - hasBufferedTurn: false), - .keepActive) - } + func testGeminiPostTurnRefreshUsesOnlyItsPersistenceFencedBoundary() { + XCTAssertFalse( + RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: .gemini)) + XCTAssertTrue( + RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: .openai)) + XCTAssertTrue( + RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: nil)) + } - func testGeminiPostTurnRefreshUsesOnlyItsPersistenceFencedBoundary() { - XCTAssertFalse( - RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: .gemini)) - XCTAssertTrue( - RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: .openai)) - XCTAssertTrue( - RealtimePersistedVoiceContextRefreshPolicy.shouldHandoffImmediately(provider: nil)) - } + func testStreamingContextUpdateDebouncesIdleSessionHandoff() { + XCTAssertEqual( + RealtimeVoiceContextRefreshPolicy.handoffDecision( + currentSnapshotIdentity: "newer", sessionSnapshotIdentity: "older", hasBufferedTurn: false), + .debounceIdleHandoff) + XCTAssertEqual( + RealtimeVoiceContextRefreshPolicy.handoffDecision( + currentSnapshotIdentity: "same", sessionSnapshotIdentity: "same", hasBufferedTurn: false), + .keepCurrentSession) + } - func testStreamingContextUpdateDebouncesIdleSessionHandoff() { - XCTAssertEqual( - RealtimeVoiceContextRefreshPolicy.handoffDecision( - currentSnapshotIdentity: "newer", sessionSnapshotIdentity: "older", hasBufferedTurn: false), - .debounceIdleHandoff) - XCTAssertEqual( - RealtimeVoiceContextRefreshPolicy.handoffDecision( - currentSnapshotIdentity: "same", sessionSnapshotIdentity: "same", hasBufferedTurn: false), - .keepCurrentSession) - } + func testCapturedPTTBypassesIdleContextDebounce() { + XCTAssertEqual( + RealtimeVoiceContextRefreshPolicy.handoffDecision( + currentSnapshotIdentity: "newer", sessionSnapshotIdentity: "older", hasBufferedTurn: true), + .replacePreservingBufferedTurn) + } - func testCapturedPTTBypassesIdleContextDebounce() { - XCTAssertEqual( - RealtimeVoiceContextRefreshPolicy.handoffDecision( - currentSnapshotIdentity: "newer", sessionSnapshotIdentity: "older", hasBufferedTurn: true), - .replacePreservingBufferedTurn) - } + func testWarmSessionWaitsForOwnerBoundVoiceContext() { + XCTAssertFalse(RealtimeWarmSessionStartPolicy.canStart(requirementIsResolved: false)) + XCTAssertTrue(RealtimeWarmSessionStartPolicy.canStart(requirementIsResolved: true)) + } - func testWarmSessionWaitsForOwnerBoundVoiceContext() { - XCTAssertFalse(RealtimeWarmSessionStartPolicy.canStart(requirementIsResolved: false)) - XCTAssertTrue(RealtimeWarmSessionStartPolicy.canStart(requirementIsResolved: true)) - } + func testIdleMaintenanceDefersWhileAnotherLogicalTurnOwnsTheSession() { + XCTAssertEqual( + RealtimeHubSessionHandoffPolicy.decide( + bindingMatchesRequirement: false, + canReplaceIdleSession: false, + hasBufferedTurn: false), + .deferUntilIdle) + } - func testIdleMaintenanceDefersWhileAnotherLogicalTurnOwnsTheSession() { - XCTAssertEqual( - RealtimeHubSessionHandoffPolicy.decide( - bindingMatchesRequirement: false, - canReplaceIdleSession: false, - hasBufferedTurn: false), - .deferUntilIdle) - } + func testCapturedTurnGetsOneTransparentRebindThenFallsBack() { + XCTAssertEqual( + RealtimeHubSessionHandoffPolicy.decide( + bindingMatchesRequirement: false, + canReplaceIdleSession: false, + hasBufferedTurn: true, + rebindAttempts: 0), + .replacePreservingBufferedTurn) + XCTAssertEqual( + RealtimeHubSessionHandoffPolicy.decide( + bindingMatchesRequirement: false, + canReplaceIdleSession: false, + hasBufferedTurn: true, + rebindAttempts: RealtimeReconnectAudioBuffer.maximumRebindAttempts + 1), + .fallbackToTranscription) + } - func testCapturedTurnGetsOneTransparentRebindThenFallsBack() { - XCTAssertEqual( - RealtimeHubSessionHandoffPolicy.decide( - bindingMatchesRequirement: false, - canReplaceIdleSession: false, - hasBufferedTurn: true, - rebindAttempts: 0), - .replacePreservingBufferedTurn) - XCTAssertEqual( - RealtimeHubSessionHandoffPolicy.decide( - bindingMatchesRequirement: false, - canReplaceIdleSession: false, - hasBufferedTurn: true, - rebindAttempts: RealtimeReconnectAudioBuffer.maximumRebindAttempts + 1), - .fallbackToTranscription) - } + func testReconnectBufferRefusesASecondRebindAttempt() { + let turnID = VoiceTurnID() + var buffer = RealtimeReconnectAudioBuffer( + turnID: turnID, + responseID: VoiceResponseID("rebind-response"), + identity: VoiceEffectIdentity(turnID: turnID, effectID: 1), + interrupting: false) + + XCTAssertTrue(buffer.beginRebindAttempt()) + XCTAssertEqual(buffer.rebindAttempts, 1) + XCTAssertFalse(buffer.beginRebindAttempt()) + XCTAssertEqual(buffer.rebindAttempts, 1) + } - func testReconnectBufferRefusesASecondRebindAttempt() { - let turnID = VoiceTurnID() - var buffer = RealtimeReconnectAudioBuffer( - turnID: turnID, - responseID: VoiceResponseID("rebind-response"), - identity: VoiceEffectIdentity(turnID: turnID, effectID: 1), - interrupting: false) - - XCTAssertTrue(buffer.beginRebindAttempt()) - XCTAssertEqual(buffer.rebindAttempts, 1) - XCTAssertFalse(buffer.beginRebindAttempt()) - XCTAssertEqual(buffer.rebindAttempts, 1) - } + func testBufferedTurnCanAdoptTheNewestRequirementBeforePhysicalReplay() { + let turnID = VoiceTurnID() + var buffer = RealtimeReconnectAudioBuffer( + turnID: turnID, + responseID: VoiceResponseID("requirement-response"), + identity: VoiceEffectIdentity(turnID: turnID, effectID: 1), + interrupting: false) + + XCTAssertTrue(buffer.bindRequiredContextFreshnessIdentity("cached-requirement")) + XCTAssertTrue(buffer.replaceRequiredContextFreshnessIdentity("fresh-requirement")) + XCTAssertEqual(buffer.requiredContextFreshnessIdentity, "fresh-requirement") + } - func testBufferedTurnCanAdoptTheNewestRequirementBeforePhysicalReplay() { - let turnID = VoiceTurnID() - var buffer = RealtimeReconnectAudioBuffer( - turnID: turnID, - responseID: VoiceResponseID("requirement-response"), - identity: VoiceEffectIdentity(turnID: turnID, effectID: 1), - interrupting: false) - - XCTAssertTrue(buffer.bindRequiredContextFreshnessIdentity("cached-requirement")) - XCTAssertTrue(buffer.replaceRequiredContextFreshnessIdentity("fresh-requirement")) - XCTAssertEqual(buffer.requiredContextFreshnessIdentity, "fresh-requirement") + @MainActor + private func installDelayedTransport( + on controller: RealtimeHubController, + ownerScope: RealtimeHubOwnerScope + ) async throws -> ( + transport: DelayedAckRealtimeTransport, + tracker: DelayedAckTransportTracker + ) { + let tracker = DelayedAckTransportTracker() + var installedTransport: DelayedAckRealtimeTransport? + let opened = expectation(description: "fixture transport opened") + let session = RealtimeHubSession( + provider: .gemini, + auth: .byokKey("fixture"), + instructions: "fixture", + rawWebSocketFactory: { _, queue in + let transport = DelayedAckRealtimeTransport(queue: queue, tracker: tracker) + transport.onOpened = { opened.fulfill() } + installedTransport = transport + return transport + }, + delegate: controller) + controller.session = session + controller.voiceSessionID = VoiceSessionID() + controller.sessionProvider = .gemini + controller.sessionAuth = .byokKey("fixture") + controller.sessionOwnerBinding = RealtimeHubController.PhysicalSessionOwnerBinding( + sourceID: ObjectIdentifier(session), + ownerScope: ownerScope) + controller.hubConnected = true + session.start() + await fulfillment(of: [opened], timeout: 1) + return (try XCTUnwrap(installedTransport), tracker) + } } - @MainActor - private func installDelayedTransport( - on controller: RealtimeHubController, - ownerScope: RealtimeHubOwnerScope - ) async throws -> ( - transport: DelayedAckRealtimeTransport, - tracker: DelayedAckTransportTracker - ) { - let tracker = DelayedAckTransportTracker() - var installedTransport: DelayedAckRealtimeTransport? - let opened = expectation(description: "fixture transport opened") - let session = RealtimeHubSession( - provider: .gemini, - auth: .byokKey("fixture"), - instructions: "fixture", - rawWebSocketFactory: { _, queue in - let transport = DelayedAckRealtimeTransport(queue: queue, tracker: tracker) - transport.onOpened = { opened.fulfill() } - installedTransport = transport - return transport - }, - delegate: controller) - controller.session = session - controller.voiceSessionID = VoiceSessionID() - controller.sessionProvider = .gemini - controller.sessionAuth = .byokKey("fixture") - controller.sessionOwnerBinding = RealtimeHubController.PhysicalSessionOwnerBinding( - sourceID: ObjectIdentifier(session), - ownerScope: ownerScope) - controller.hubConnected = true - session.start() - await fulfillment(of: [opened], timeout: 1) - return (try XCTUnwrap(installedTransport), tracker) - } -} + private final class DelayedAckTransportTracker: @unchecked Sendable { + private let lock = NSLock() + private var live = 0 -private final class DelayedAckTransportTracker: @unchecked Sendable { - private let lock = NSLock() - private var live = 0 + var liveCount: Int { lock.withLock { live } } - var liveCount: Int { lock.withLock { live } } + func opened() { + lock.withLock { live += 1 } + } - func opened() { - lock.withLock { live += 1 } + func closed() { + lock.withLock { live -= 1 } + } } - func closed() { - lock.withLock { live -= 1 } - } -} - -private final class DelayedAckRealtimeTransport: RealtimeRawWebSocketTransport, - @unchecked Sendable -{ - var onOpen: (() -> Void)? - var onMessage: ((Data) -> Void)? - var onClose: ((Int, String) -> Void)? - var onError: ((RealtimeRawWebSocketFailure) -> Void)? - var onOpened: (() -> Void)? - - private let queue: DispatchQueue - private let tracker: DelayedAckTransportTracker - private var open = false - private var waiters: [CheckedContinuation] = [] - - init(queue: DispatchQueue, tracker: DelayedAckTransportTracker) { - self.queue = queue - self.tracker = tracker - } + private final class DelayedAckRealtimeTransport: RealtimeRawWebSocketTransport, + @unchecked Sendable + { + var onOpen: (() -> Void)? + var onMessage: ((Data) -> Void)? + var onClose: ((Int, String) -> Void)? + var onError: ((RealtimeRawWebSocketFailure) -> Void)? + var onOpened: (() -> Void)? + + private let queue: DispatchQueue + private let tracker: DelayedAckTransportTracker + private var open = false + private var waiters: [CheckedContinuation] = [] + + init(queue: DispatchQueue, tracker: DelayedAckTransportTracker) { + self.queue = queue + self.tracker = tracker + } - func connect() { - open = true - tracker.opened() - onOpened?() - onOpen?() - } + func connect() { + open = true + tracker.opened() + onOpened?() + onOpen?() + } - func sendText(_ text: String, completion: (@Sendable (Error?) -> Void)?) { - completion?(nil) - } + func sendText(_ text: String, completion: (@Sendable (Error?) -> Void)?) { + completion?(nil) + } - func close() {} + func close() {} - func closeAndWait() async { - await withCheckedContinuation { continuation in - queue.async { [weak self] in - guard let self else { - continuation.resume() - return - } - if self.open { - self.waiters.append(continuation) - } else { - continuation.resume() + func closeAndWait() async { + await withCheckedContinuation { continuation in + queue.async { [weak self] in + guard let self else { + continuation.resume() + return + } + if self.open { + self.waiters.append(continuation) + } else { + continuation.resume() + } } } } - } - func acknowledgeClose() { - queue.async { [weak self] in - guard let self, self.open else { return } - self.open = false - self.tracker.closed() - let waiters = self.waiters - self.waiters.removeAll() - for waiter in waiters { - waiter.resume() + func acknowledgeClose() { + queue.async { [weak self] in + guard let self, self.open else { return } + self.open = false + self.tracker.closed() + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } } } - } - func emitErrorCloseAndDuplicateError() { - queue.async { [weak self] in - guard let self else { return } - let failure = RealtimeRawWebSocketFailure( - phase: .receive, - message: "fixture transport failure") - self.onError?(failure) - self.onClose?(1011, "fixture remote close reason") - self.onError?(failure) + func emitErrorCloseAndDuplicateError() { + queue.async { [weak self] in + guard let self else { return } + let failure = RealtimeRawWebSocketFailure( + phase: .receive, + message: "fixture transport failure") + self.onError?(failure) + self.onClose?(1011, "fixture remote close reason") + self.onError?(failure) + } } } -} +#endif