mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(chat): preserve opaque native conversation identities (#162818)
## What Problem This Solves Native chat lowercased complete session keys when admitting Gateway events. Distinct Matrix rooms/threads, Signal groups, or catalog conversations whose opaque IDs differed only by case could therefore appear in the selected transcript. ## User Impact Native chat keeps those conversations separate while continuing to accept structural routing aliases. Stored keys, Gateway wire formats, main/global routing, and composer identity are unchanged. ## Why This Change Was Made The sidebar already implemented the comparison contract used by the Control UI. Move that implementation to the existing shared session-key owner and use it for sidebar, native event filtering, and the public default-main matcher. Catalog bodies remain fully opaque; the canonical Control UI contract normalizes only their agent prefix. Matrix/Signal routing words retain their existing normalization. A five-case regression passes actual `session.message` frames through the payload codec and registered dispatcher. Each case rejects a different opaque ID and accepts its structural alias. The existing public-matcher table also covers its separate Talk-facing API contract. ## Evidence - Remote `check-changed` passed on Blacksmith Testbox. - Both import-cycle checks passed with **0 cycles**. - Focused source review and isolated independent P2 review completed. The one review concern about lowercasing the catalog discriminator was rejected against the existing Control UI source contract, which deliberately preserves the complete catalog body. - Swift formatting and `git diff --check` passed. - Exact-head hosted OpenClawKit CI passed 2,000 tests in 168 suites (35.259s), plus 21 NativeState tests. The new five-case frame/dispatch regression passed in 5.206s including concurrent-suite scheduling. Isolated-file wall time was not measured. The regression has not been executed against the original implementation: Blacksmith supports Linux only, and the AWS existing-host Mac route returned no available Dedicated Host. The original whole-key lowercase path and its event-dispatch effect were traced directly; this is source evidence, not an observed baseline test failure. - Tests add no sleeps, polling, process boots, or production seams. The full macOS app and iOS smoke jobs remain separate hosted evidence. Found during the sibling duplication investigation. The production change removes one net line by sharing the existing comparison owner; tests add 52 lines and documentation adds three. ## Fixes found along the way The macOS cloud-worker fixture signaled readiness through file existence while `printf` was still writing its argument capture. Hosted run 36887822355 observed output ending at `--ephemeral`, before the final display-name arguments. Publish the complete capture with a same-directory temporary file and atomic rename; every original assertion and timeout stays intact. This fixture-only repair has a clean independent P2 review. Linux Testbox proof passed 25 runs per enrollment mode (50 total), two gated atomic-publication controls, and two original-publication adverse controls. The proof verified the exact committed fixture bytes and preserved every argument, including a Unicode path. Both cycle checks again reported zero. This proves shell fixture publication; updated-head Mac ProcessIdentity/AppKit and full native CI remain separate evidence. That run also encountered `AXError.attributeUnsupported (-25205)` while the test helper requested the application's accessibility windows, before GatewayInstallerView's text/action assertions. The same helper failure is documented in #158049 without a proven fix. The affected owners are unchanged by this PR, recent inspected main runs passed, and no matching current-main failure has been established. The assertion is retained; this earlier failure is not claimed fixed or bypassed.
This commit is contained in:
parent
dc73f4bb40
commit
84dc4c73ce
7 changed files with 100 additions and 45 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)..<parts.count {
|
||||
parts[index] = parts[index].lowercased()
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: ":")
|
||||
}
|
||||
|
||||
public static func agentID(from sessionKey: String?) -> String? {
|
||||
let parts = (sessionKey ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
|
|||
|
|
@ -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)..<parts.count {
|
||||
parts[index] = parts[index].lowercased()
|
||||
}
|
||||
}
|
||||
return parts.joined(separator: ":")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -401,12 +401,10 @@ extension OpenClawChatViewModel {
|
|||
-> 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue