fix: route macos voice wake to selected session

This commit is contained in:
Peter Steinberger
2026-05-02 02:53:56 +01:00
parent 6f52b06f9f
commit ecef57831c
8 changed files with 184 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.

View File

@@ -348,7 +348,7 @@ actor VoicePushToTalk {
VoiceWakeChimePlayer.play(chime, reason: "ptt.fallback_send")
}
Task.detached {
await VoiceWakeForwarder.forward(transcript: finalText)
await VoiceWakeForwarder.forwardToSelectedSession(transcript: finalText)
}
}
}

View File

@@ -103,10 +103,9 @@ final class VoiceSessionCoordinator {
}
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: sendChime)
Task.detached {
_ = await VoiceWakeForwarder.forward(
_ = await VoiceWakeForwarder.forwardToSelectedSession(
transcript: text,
options: .init(
voiceWakeTrigger: voiceWakeTrigger))
voiceWakeTrigger: voiceWakeTrigger)
}
}

View File

@@ -41,6 +41,78 @@ enum VoiceWakeForwarder {
var voiceWakeTrigger: String?
}
private struct SessionListResponse: Decodable {
let sessions: [SessionRouteEntry]
}
struct SessionRouteEntry: Decodable, Equatable {
let key: String
let channel: String?
let lastChannel: String?
let lastTo: String?
let deliveryContext: DeliveryContext?
}
struct DeliveryContext: Decodable, Equatable {
let channel: String?
let to: String?
}
static func selectedSessionOptions(voiceWakeTrigger: String? = nil) async -> ForwardOptions {
let activeSessionKey = await MainActor.run { WebChatManager.shared.activeSessionKey }
let sessionKey: String = if let activeSessionKey = activeSessionKey?.trimmingCharacters(
in: .whitespacesAndNewlines),
!activeSessionKey.isEmpty
{
activeSessionKey
} else {
await GatewayConnection.shared.mainSessionKey()
}
let routeEntry = await self.loadSessionRouteEntry(sessionKey: sessionKey)
return self.forwardOptions(
sessionKey: sessionKey,
routeEntry: routeEntry,
voiceWakeTrigger: voiceWakeTrigger)
}
static func forwardOptions(
sessionKey: String,
routeEntry: SessionRouteEntry?,
voiceWakeTrigger: String? = nil) -> ForwardOptions
{
let parsedRoute = self.parseSessionKeyRoute(sessionKey)
let channelRaw = self.firstNonEmpty(
routeEntry?.deliveryContext?.channel,
routeEntry?.lastChannel,
routeEntry?.channel,
parsedRoute?.channel)
let channel = channelRaw
.flatMap { GatewayAgentChannel(rawValue: $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) }
?? .webchat
let to = self.firstNonEmpty(
routeEntry?.deliveryContext?.to,
routeEntry?.lastTo,
parsedRoute?.to)
return ForwardOptions(
sessionKey: sessionKey,
thinking: "low",
deliver: true,
to: to,
channel: channel,
voiceWakeTrigger: voiceWakeTrigger)
}
@discardableResult
static func forwardToSelectedSession(
transcript: String,
voiceWakeTrigger: String? = nil) async -> Result<Void, VoiceWakeForwardError>
{
let options = await self.selectedSessionOptions(voiceWakeTrigger: voiceWakeTrigger)
return await self.forward(transcript: transcript, options: options)
}
@discardableResult
static func forward(
transcript: String,
@@ -72,4 +144,56 @@ enum VoiceWakeForwarder {
if status.ok { return .success(()) }
return .failure(.rpcFailed(status.error ?? "agent rpc unreachable"))
}
private static func loadSessionRouteEntry(sessionKey: String) async -> SessionRouteEntry? {
do {
let data = try await GatewayConnection.shared.request(
method: "sessions.list",
params: [
"includeGlobal": AnyCodable(false),
"includeUnknown": AnyCodable(false),
"limit": AnyCodable(500),
],
timeoutMs: 10000)
let response = try JSONDecoder().decode(SessionListResponse.self, from: data)
return response.sessions.first {
$0.key.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame
}
} catch {
self.logger.debug(
"voice wake selected route lookup failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private static func parseSessionKeyRoute(_ sessionKey: String) -> (channel: String, to: String?)? {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let rawParts = trimmed.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
let body: [String] = if rawParts.count >= 3, rawParts[0].caseInsensitiveCompare("agent") == .orderedSame {
Array(rawParts.dropFirst(2))
} else {
rawParts
}
guard body.count >= 3 else { return nil }
let kind = body[1].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard kind == "direct" || kind == "group" || kind == "channel" else { return nil }
let channel = body[0].trimmingCharacters(in: .whitespacesAndNewlines)
guard !channel.isEmpty else { return nil }
let to = body.dropFirst(2)
.joined(separator: ":")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (channel: channel, to: to.isEmpty ? nil : to)
}
private static func firstNonEmpty(_ values: String?...) -> String? {
for value in values {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty {
return trimmed
}
}
return nil
}
}

View File

@@ -694,9 +694,9 @@ actor VoiceWakeRuntime {
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
}
Task.detached {
await VoiceWakeForwarder.forward(
await VoiceWakeForwarder.forwardToSelectedSession(
transcript: finalTranscript,
options: .init(voiceWakeTrigger: triggerWord))
voiceWakeTrigger: triggerWord)
}
}
self.overlayToken = nil

View File

@@ -30,12 +30,13 @@ final class WebChatManager {
private var windowSessionKey: String?
private var panelController: WebChatSwiftUIWindowController?
private var panelSessionKey: String?
private var currentChatSessionKey: String?
private var cachedPreferredSessionKey: String?
var onPanelVisibilityChanged: ((Bool) -> Void)?
var activeSessionKey: String? {
self.panelSessionKey ?? self.windowSessionKey
self.currentChatSessionKey ?? self.panelSessionKey ?? self.windowSessionKey
}
func show(sessionKey: String) {
@@ -56,6 +57,7 @@ final class WebChatManager {
}
self.windowController = controller
self.windowSessionKey = sessionKey
self.currentChatSessionKey = sessionKey
controller.show()
}
@@ -86,9 +88,16 @@ final class WebChatManager {
}
self.panelController = controller
self.panelSessionKey = sessionKey
self.currentChatSessionKey = sessionKey
controller.presentAnchored(anchorProvider: anchorProvider)
}
func recordActiveSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.currentChatSessionKey = trimmed
}
func closePanel() {
self.panelController?.close()
}
@@ -107,6 +116,7 @@ final class WebChatManager {
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
self.currentChatSessionKey = nil
self.cachedPreferredSessionKey = nil
}

View File

@@ -134,6 +134,9 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
}
func setActiveSessionKey(_ sessionKey: String) async throws {
await MainActor.run {
WebChatManager.shared.recordActiveSessionKey(sessionKey)
}
_ = try await GatewayConnection.shared.request(
method: "sessions.messages.subscribe",
params: ["key": AnyCodable(sessionKey)],

View File

@@ -20,4 +20,44 @@ import Testing
#expect(opts.channel == .webchat)
#expect(opts.channel.shouldDeliver(opts.deliver) == false)
}
@Test func `selected forward options use session delivery context`() {
let entry = VoiceWakeForwarder.SessionRouteEntry(
key: "agent:main:telegram:group:6812765697",
channel: "telegram",
lastChannel: "telegram",
lastTo: "telegram:6812765697",
deliveryContext: .init(channel: "telegram", to: "telegram:6812765697"))
let opts = VoiceWakeForwarder.forwardOptions(
sessionKey: entry.key,
routeEntry: entry,
voiceWakeTrigger: "open claw")
#expect(opts.sessionKey == "agent:main:telegram:group:6812765697")
#expect(opts.channel == .telegram)
#expect(opts.to == "telegram:6812765697")
#expect(opts.voiceWakeTrigger == "open claw")
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}
@Test func `selected forward options parse channel scoped session fallback`() {
let opts = VoiceWakeForwarder.forwardOptions(
sessionKey: "agent:main:discord:channel:123:456",
routeEntry: nil)
#expect(opts.channel == .discord)
#expect(opts.to == "123:456")
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
}
@Test func `selected forward options keep internal sessions on webchat`() {
let opts = VoiceWakeForwarder.forwardOptions(
sessionKey: "agent:main:work",
routeEntry: nil)
#expect(opts.channel == .webchat)
#expect(opts.to == nil)
#expect(opts.channel.shouldDeliver(opts.deliver) == false)
}
}