diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 5328a5b692f..4dc8b9d8b14 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -170,7 +170,9 @@ public final class OpenClawChatViewModel { } let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.decodeMessages(payload.messages ?? []) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) self.sessionId = payload.sessionId if let level = payload.thinkingLevel, !level.isEmpty { self.thinkingLevel = level @@ -191,6 +193,70 @@ public final class OpenClawChatViewModel { return Self.dedupeMessages(decoded) } + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !role.isEmpty else { return nil } + + let timestamp: String = { + guard let value = message.timestamp, value.isFinite else { return "" } + return String(format: "%.3f", value) + }() + + let contentFingerprint = message.content.map { item in + let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return [type, text, id, name, fileName].joined(separator: "\\u{001F}") + }.joined(separator: "\\u{001E}") + + let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { + return nil + } + return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|") + } + + private static func reconcileMessageIDs( + previous: [OpenClawChatMessage], + incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] + { + guard !previous.isEmpty, !incoming.isEmpty else { return incoming } + + var idsByKey: [String: [UUID]] = [:] + for message in previous { + guard let key = Self.messageIdentityKey(for: message) else { continue } + idsByKey[key, default: []].append(message.id) + } + + return incoming.map { message in + guard let key = Self.messageIdentityKey(for: message), + var ids = idsByKey[key], + let reusedId = ids.first + else { + return message + } + ids.removeFirst() + if ids.isEmpty { + idsByKey.removeValue(forKey: key) + } else { + idsByKey[key] = ids + } + guard reusedId != message.id else { return message } + return OpenClawChatMessage( + id: reusedId, + role: message.role, + content: message.content, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + } + private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { var result: [OpenClawChatMessage] = [] result.reserveCapacity(messages.count) @@ -375,11 +441,15 @@ public final class OpenClawChatViewModel { } private func handleChatEvent(_ chat: OpenClawChatEventPayload) { - if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey { + let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false + + // Gateway may publish canonical session keys (for example "agent:main:main") + // 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 { return } - - let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false if !isOurRun { // Keep multiple clients in sync: if another client finishes a run for our session, refresh history. switch chat.state { @@ -444,7 +514,9 @@ public final class OpenClawChatViewModel { private func refreshHistoryAfterRun() async { do { let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.decodeMessages(payload.messages ?? []) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) self.sessionId = payload.sessionId if let level = payload.thinkingLevel, !level.isEmpty { self.thinkingLevel = level diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 3babe8b9a30..852ae0e7ff0 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -215,6 +215,103 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } + @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from history"]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + 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.healthOK } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + try await waitUntil("history refresh") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + } + + @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "world"]], + "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 } } + let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id }) + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "other-run", + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } } + let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id }) + #expect(firstIdAfter == firstIdBefore) + } + @Test func clearsStreamingOnExternalFinalEvent() async throws { let sessionId = "sess-main" let history = OpenClawChatHistoryPayload(