diff --git a/apps/macos/Tests/OpenClawIPCTests/CloudWorkerHostTests.swift b/apps/macos/Tests/OpenClawIPCTests/CloudWorkerHostTests.swift index 355dd3082d29..1bef48bf2453 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CloudWorkerHostTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CloudWorkerHostTests.swift @@ -214,7 +214,8 @@ struct CloudWorkerHostTests { let cliDirectory = root.appendingPathComponent("runtime/node_modules/openclaw", isDirectory: true) try FileManager.default.createDirectory(at: cliDirectory, withIntermediateDirectories: true) try """ - printf '%s\\n' "$OPENCLAW_STATE_DIR" "$OPENCLAW_CUA_DRIVER_ENDPOINT" "$@" > "$OPENCLAW_STATE_DIR/observed" + printf '%s\\n' "$OPENCLAW_STATE_DIR" "$OPENCLAW_CUA_DRIVER_ENDPOINT" "$@" > "$OPENCLAW_STATE_DIR/observed.pending" && + /bin/mv "$OPENCLAW_STATE_DIR/observed.pending" "$OPENCLAW_STATE_DIR/observed" exec /bin/sleep 30 """.write(to: cliDirectory.appendingPathComponent("openclaw.mjs"), atomically: true, encoding: .utf8) try "synthetic-enrollment".write( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 0088c5ed8f56..1552f6db2b15 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -4,8 +4,8 @@ import OpenClawProtocol public enum OpenClawChatSessionKey { public static func matchesIncludingDefaultMainAlias(_ incoming: String, _ current: String) -> Bool { - let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let incoming = self.comparisonKey(incoming) + let current = self.comparisonKey(current) if incoming == current { return true } @@ -13,6 +13,41 @@ public enum OpenClawChatSessionKey { (incoming == "main" && current == "agent:main:main") } + /// Match the Control UI's session-key comparison without folding opaque channel identifiers. + static func comparisonKey(_ key: String) -> String { + let raw = key.trimmingCharacters(in: .whitespacesAndNewlines) + var parts = raw.components(separatedBy: ":") + var start = 0 + while parts.count - start >= 3, parts[start].lowercased() == "agent" { + parts[start] = "agent" + parts[start + 1] = parts[start + 1].lowercased() + start += 2 + } + while start < parts.count, parts[start].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + start += 1 + } + guard start < parts.count else { return raw.lowercased() } + let channel = parts[start].lowercased() + if channel == "catalog" { return parts.joined(separator: ":") } + guard start + 1 < parts.count else { return raw.lowercased() } + let peer = parts[start + 1].lowercased() + let matrix = channel == "matrix" && ["channel", "group"].contains(peer) + guard matrix || (channel == "signal" && peer == "group") else { return raw.lowercased() } + parts[start] = channel + parts[start + 1] = peer + if matrix { + if let index = parts.indices.reversed().first(where: { + $0 >= start + 2 && $0 < parts.count - 1 && parts[$0].lowercased() == "thread" + }) { parts[index] = "thread" } + } else if start + 2 < parts.count { + parts[start + 2] = parts[start + 2].trimmingCharacters(in: .whitespacesAndNewlines) + for index in (start + 3).. String? { let parts = (sessionKey ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarPeople.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarPeople.swift index ec876f1bdc70..32cdf952f449 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarPeople.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessionSidebarPeople.swift @@ -292,7 +292,7 @@ public final class OpenClawChatSidebarPeople { let canonical: String = if tail == "main" || tail == self.mainKey { self.globalScope ? "global" : "agent:\(scope):\(self.mainKey)" } else { - Self.comparisonKey(parsedAgent != nil || raw.lowercased() == "global" + OpenClawChatSessionKey.comparisonKey(parsedAgent != nil || raw.lowercased() == "global" ? raw : "agent:\(scope):\(raw)") } return "\(scope)\0\(canonical)" @@ -323,40 +323,6 @@ public final class OpenClawChatSidebarPeople { visible, visible.map { identity($0.key, $0.agentId) }, selected, selected.map { identity($0.key, $0.agentId) }) } - - // ui/src/lib/sessions/session-key.ts:90: catalog, Matrix and Signal IDs have opaque, case-sensitive tails. - private static func comparisonKey(_ raw: String) -> String { - var parts = raw.components(separatedBy: ":") - var start = 0 - while parts.count - start >= 3, parts[start].lowercased() == "agent" { - parts[start] = "agent" - parts[start + 1] = parts[start + 1].lowercased() - start += 2 - } - while start < parts.count, parts[start].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - start += 1 - } - guard start < parts.count else { return raw.lowercased() } - let channel = parts[start].lowercased() - if channel == "catalog" { return parts.joined(separator: ":") } - guard start + 1 < parts.count else { return raw.lowercased() } - let peer = parts[start + 1].lowercased() - let matrix = channel == "matrix" && ["channel", "group"].contains(peer) - guard matrix || (channel == "signal" && peer == "group") else { return raw.lowercased() } - parts[start] = channel - parts[start + 1] = peer - if matrix { - if let index = parts.indices.reversed().first(where: { - $0 >= start + 2 && $0 < parts.count - 1 && parts[$0].lowercased() == "thread" - }) { parts[index] = "thread" } - } else if start + 2 < parts.count { - parts[start + 2] = parts[start + 2].trimmingCharacters(in: .whitespacesAndNewlines) - for index in (start + 3).. Bool { let routing = OpenClawChatSessionRoutingContract.parse(sessionRoutingContract) - let incomingNormalized = ChatSessionNavigation.comparisonKey( - incoming, agentID: agentId, scope: routing?.scope, mainKey: routing?.mainKey) - .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let currentNormalized = ChatSessionNavigation.comparisonKey( - current, agentID: activeAgentId, scope: routing?.scope, mainKey: routing?.mainKey) - .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let incomingNormalized = OpenClawChatSessionKey.comparisonKey(ChatSessionNavigation.comparisonKey( + incoming, agentID: agentId, scope: routing?.scope, mainKey: routing?.mainKey)) + let currentNormalized = OpenClawChatSessionKey.comparisonKey(ChatSessionNavigation.comparisonKey( + current, agentID: activeAgentId, scope: routing?.scope, mainKey: routing?.mainKey)) if incomingNormalized == currentNormalized { if OpenClawChatSessionKey.agentID(from: currentNormalized) == nil { // `global` is always agent-ambiguous. Ordinary exact keys can @@ -426,7 +424,7 @@ extension OpenClawChatViewModel { return true } - let mainNormalized = mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let mainNormalized = OpenClawChatSessionKey.comparisonKey(mainSessionKey) if Self.matchesMainAlias( incoming: incomingNormalized, current: currentNormalized, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionKeyTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionKeyTests.swift index 7de6afc8afca..c3924d2c1521 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionKeyTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatSessionKeyTests.swift @@ -9,6 +9,8 @@ struct ChatSessionKeyTests { ("main", "agent:other:main", false), ("agent:main:other", "other", false), ("chat-1", "chat-2", false), + ("Matrix:Channel:!Room:example.org", "matrix:channel:!Room:example.org", true), + ("matrix:channel:!Room:example.org", "matrix:channel:!room:example.org", false), ]) func `Talk matches session keys without admitting other agent or named-session aliases`( incoming: String, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 63df56eb4541..ef2faf9243db 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -7256,6 +7256,56 @@ struct ChatViewModelTests { #expect(!matches.2) } + @Test(arguments: [ + ( + "agent:ops:catalog:fixture:node%3ADevBox:Thread%3AA", + "Agent:OPS:catalog:fixture:node%3ADevBox:Thread%3AA", + "agent:ops:catalog:fixture:node%3ADevBox:thread%3Aa"), + ( + "agent:ops:matrix:channel:!Room:Example.Org", + "Agent:OPS:Matrix:Channel:!Room:Example.Org", + "agent:ops:matrix:channel:!room:example.org"), + ( + "agent:ops:matrix:channel:!Room:Example.Org:thread:$Event", + "Agent:OPS:Matrix:Channel:!Room:Example.Org:THREAD:$Event", + "agent:ops:matrix:channel:!Room:Example.Org:thread:$event"), + ( + "agent:ops:signal:group:AbC123=", + "Agent:OPS:Signal:Group:AbC123=", + "agent:ops:signal:group:abc123="), + ( + "agent:ops:signal:group:AbC123=:thread:xyz", + "Agent:OPS:Signal:Group:AbC123=:Thread:XyZ", + "agent:ops:signal:group:abc123=:thread:xyz"), + ]) @MainActor + func `session message events preserve opaque conversation identity`( + keys: (selected: String, alias: String, distinct: String)) async throws + { + let (_, vm) = await makeViewModel( + sessionKey: keys.selected, + activeAgentId: "ops", + historyResponses: []) + defer { vm.detachTransport() } + + func deliver(sessionKey: String, text: String) throws { + let event = try #require(OpenClawChatGatewayPayloadCodec.event(from: EventFrame( + type: "event", event: "session.message", + payload: AnyCodable([ + "sessionKey": sessionKey, + "agentId": "ops", + "messageId": text, + "message": chatTextMessage(role: "user", text: text, timestamp: 1).value, + ])))) + vm.handleTransportEvent(event) + } + + try deliver(sessionKey: keys.distinct, text: "foreign conversation") + #expect(vm.messages.isEmpty) + + try deliver(sessionKey: keys.alias, text: "selected conversation") + #expect(vm.messages.flatMap(\.content).compactMap(\.text) == ["selected conversation"]) + } + @Test func `ignores agent main session message for different current main alias`() async throws { let now = Date().timeIntervalSince1970 * 1000 let (transport, vm) = await makeViewModel(historyResponses: [historyPayload()]) diff --git a/docs/concepts/session.md b/docs/concepts/session.md index cd61337d80f1..9933c29a32e1 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -28,6 +28,9 @@ DM channels, with group activity and background work flowing into it — see | Cron jobs | Fresh session per run | | Webhooks | Isolated per hook | +Native catalog source IDs, Matrix room and thread IDs, and Signal group IDs are +case-sensitive: IDs that differ only by case identify different conversations. + With `session.scope: "global"`, the selected agent still owns its session. The shared key `global` does not merge different agents' conversations: commands, skills, replies, and background task notifications retain the