diff --git a/CHANGELOG.md b/CHANGELOG.md index a52ab9be370..0b0f74ece8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky. - iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. - OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 4dc8b9d8b14..fc7b399353d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -447,7 +447,10 @@ public final class OpenClawChatViewModel { // even when this view currently uses an alias key (for example "main"). // Never drop events for our own pending run on key mismatch, or the UI can stay // stuck at "thinking" until the user reopens and forces a history reload. - if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey, !isOurRun { + if let sessionKey = chat.sessionKey, + !Self.matchesCurrentSessionKey(incoming: sessionKey, current: self.sessionKey), + !isOurRun + { return } if !isOurRun { @@ -481,6 +484,21 @@ public final class OpenClawChatViewModel { } } + private static func matchesCurrentSessionKey(incoming: String, current: String) -> Bool { + let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if incomingNormalized == currentNormalized { + return true + } + // Common alias pair in operator clients: UI uses "main" while gateway emits canonical. + if (incomingNormalized == "agent:main:main" && currentNormalized == "main") || + (incomingNormalized == "main" && currentNormalized == "agent:main:main") + { + return true + } + return false + } + private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) { if let sessionId, evt.runId != sessionId { return diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 852ae0e7ff0..ff7caabf381 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -261,6 +261,56 @@ extension TestChatTransportState { } } + @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from external run"]], + "timestamp": now + 1, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "external-run", + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh after canonical external event") { + await MainActor.run { vm.messages.count == 2 } + } + } + @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = OpenClawChatHistoryPayload(