fix(ios): surface Voice Wake transcript delivery failures (#130139)

This commit is contained in:
Peter Steinberger
2026-08-26 06:59:55 -07:00
committed by GitHub
parent 6124d47e04
commit bb66c4c288
4 changed files with 45 additions and 15 deletions
+1 -6
View File
@@ -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)
+13 -8
View File
@@ -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<VoiceWakeSuppressionReason> = []
@@ -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)
}
@@ -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`() {
+1
View File
@@ -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.