From bb66c4c288deaefa277a1b8bb84e45f1dd36c085 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 06:59:55 -0700 Subject: [PATCH] fix(ios): surface Voice Wake transcript delivery failures (#130139) --- apps/ios/Sources/Model/NodeAppModel.swift | 7 +---- apps/ios/Sources/Voice/VoiceWakeManager.swift | 21 ++++++++----- .../Tests/VoiceWakeManagerStateTests.swift | 31 ++++++++++++++++++- docs/platforms/ios.md | 1 + 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 4f8b43a22e0b..fe8bf1526c38 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1030,12 +1030,7 @@ final class NodeAppModel { self.voiceWake.configure { [weak self] cmd in guard let self else { return } - let sessionKey = await MainActor.run { self.mainSessionKey } - do { - try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey) - } catch { - // Best-effort only. - } + try await self.sendVoiceTranscript(text: cmd, sessionKey: self.mainSessionKey) } self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in self?.voiceWake.setSuppressedByVoiceNote(isActive) diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index 03a9e59c642e..46714d59a729 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -119,7 +119,7 @@ final class VoiceWakeManager: NSObject { private var audioSessionIsActive = false private var lastDispatched: String? - private var onCommand: (@Sendable (String) async -> Void)? + private var onCommand: (@MainActor @Sendable (String) async throws -> Void)? private var userDefaultsObserver: NSObjectProtocol? private var suppressionReasons: Set = [] @@ -166,7 +166,7 @@ final class VoiceWakeManager: NSObject { } } - func configure(onCommand: @escaping @Sendable (String) async -> Void) { + func configure(onCommand: @escaping @MainActor @Sendable (String) async throws -> Void) { self.onCommand = onCommand } @@ -472,12 +472,21 @@ final class VoiceWakeManager: NSObject { self.commandTask = nil } } - await self.onCommand?(cmd) + do { + try await self.onCommand?(cmd) + } catch { + guard !(error is CancellationError), self.isCurrentCommand( + recognitionGeneration: recognitionGeneration, + commandGeneration: commandGeneration) + else { return } + self.statusText = error.localizedDescription + return + } guard self.isCurrentCommand( recognitionGeneration: recognitionGeneration, commandGeneration: commandGeneration) else { return } - await self.startIfEnabled() + self.scheduleStart() } } @@ -498,10 +507,6 @@ final class VoiceWakeManager: NSObject { self.commandTask = nil } - private func startIfEnabled() async { - self.scheduleStart() - } - private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? { Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords) } diff --git a/apps/ios/Tests/VoiceWakeManagerStateTests.swift b/apps/ios/Tests/VoiceWakeManagerStateTests.swift index a56238f9beef..f9ecfb086c3e 100644 --- a/apps/ios/Tests/VoiceWakeManagerStateTests.swift +++ b/apps/ios/Tests/VoiceWakeManagerStateTests.swift @@ -84,6 +84,30 @@ private actor VoiceWakeCommandBarrier { #expect(await capture.value == "hello") } + @Test @MainActor func `failed Voice Wake delivery replaces the triggered status`() async throws { + let appModel = NodeAppModel() + let manager = appModel.voiceWake + manager.triggerWords = ["openclaw"] + manager.isEnabled = true + + let transcript = "openclaw hello" + let triggerRange = try #require(transcript.range(of: "openclaw")) + let commandRange = try #require(transcript.range(of: "hello")) + manager._test_handleRecognitionCallback( + transcript: transcript, + segments: [ + WakeWordSegment(text: "openclaw", start: 0, duration: 0.2, range: triggerRange), + WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: commandRange), + ], + errorText: nil) + + for _ in 0..<100 where manager.statusText == "Triggered" { + await Task.yield() + } + + #expect(manager.statusText == "Gateway not connected") + } + @Test @MainActor func `suppression cancels an admitted command before dispatch`() async throws { let manager = VoiceWakeManager._test_withoutRestartDelays() let barrier = VoiceWakeCommandBarrier() @@ -99,7 +123,11 @@ private actor VoiceWakeCommandBarrier { manager.isListening = true manager.configure { command in await barrier.suspend() - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + throw NSError(domain: "VoiceWakeManagerStateTests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Stale delivery failed", + ]) + } await capture.set(command) } @@ -126,6 +154,7 @@ private actor VoiceWakeCommandBarrier { #expect(await barrier.observedCancellation == true) #expect(await capture.value == nil) + #expect(manager.statusText == "Paused") } @Test @MainActor func `Voice Wake deactivates only its owned audio session`() { diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 6481f75cf675..abf33b303989 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -318,6 +318,7 @@ Agents can still operate the iOS app through OpenClaw by invoking node commands, ## Voice wake + talk mode - Voice wake and talk mode are available in Settings. +- Voice wake sends recognized commands to the active session and shows Gateway delivery failures in Settings; use talk mode for spoken assistant replies. - OpenAI realtime Talk uses client-owned WebRTC when `talk.realtime.transport` is `webrtc`; an explicit `gateway-relay` configuration remains Gateway-owned. See [Talk mode](/nodes/talk). - Talk-capable iOS nodes advertise the `talk` capability and can declare `talk.ptt.start`, `talk.ptt.stop`, `talk.ptt.cancel`, and `talk.ptt.once`; the Gateway allows those push-to-talk commands by default for trusted Talk-capable nodes. - iOS may suspend background audio; treat voice features as best-effort when the app is not active.