diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index 2071e3cd7c88..1ba54fe3d2e2 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -1,9 +1,9 @@ import Foundation -import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Testing @testable import OpenClaw +@testable import OpenClawChatUI struct IOSGatewayChatTransportTests { private actor RequestRecorder { @@ -354,11 +354,104 @@ struct IOSGatewayChatTransportTests { #expect(message.messageSeq == 7) #expect(message.message?.role == "assistant") #expect(message.message?.content.first?.text == "agent reply") + #expect(message.message?.transcriptMessageID == "msg-1") default: Issue.record("expected .sessionMessage from session.message event, got \(String(describing: mapped))") } } + @Test @MainActor func `canonical transcript identity deduplicates replayed assistant messages`() { + let original = Self.canonicalAssistantMessage(timestamp: 1234.5) + let replay = Self.canonicalAssistantMessage(timestamp: 5678.5) + + let messages = OpenClawChatViewModel.dedupeMessages([original, replay]) + + #expect(messages.count == 1) + #expect(messages.first?.transcriptMessageID == "canonical-assistant-1") + } + + @Test @MainActor func `distinct transcript identities preserve identical assistant replies`() { + let first = Self.canonicalAssistantMessage(timestamp: 1234.5) + let second = Self.canonicalAssistantMessage( + timestamp: 1234.5, + transcriptMessageID: "canonical-assistant-2") + + let messages = OpenClawChatViewModel.dedupeMessages([first, second]) + + #expect(messages.count == 2) + #expect(messages.map(\.transcriptMessageID) == ["canonical-assistant-1", "canonical-assistant-2"]) + } + + @Test @MainActor func `history reconciles a replay by its canonical transcript identity`() { + let original = Self.canonicalAssistantMessage(timestamp: 1234.5) + let replay = Self.canonicalAssistantMessage(timestamp: 5678.5) + + let messages = OpenClawChatViewModel.reconcileMessageIDs( + previous: [original], + incoming: [replay]) + + #expect(messages.count == 1) + #expect(messages.first?.id == original.id) + #expect(messages.first?.timestamp == replay.timestamp) + #expect(messages.first?.transcriptMessageID == "canonical-assistant-1") + } + + @Test @MainActor func `canonical adoption keeps the durable transcript identity`() { + let existing = OpenClawChatMessage( + role: "assistant", + content: [Self.assistantText], + timestamp: 1234.5) + let incoming = Self.canonicalAssistantMessage(timestamp: 5678.5) + + let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing) + + #expect(adopted.id == existing.id) + #expect(adopted.timestamp == incoming.timestamp) + #expect(adopted.transcriptMessageID == "canonical-assistant-1") + } + + @Test @MainActor func `user idempotency still reconciles an optimistic canonical echo`() { + let original = OpenClawChatMessage( + role: "user", + content: [Self.assistantText], + timestamp: 1234.5, + idempotencyKey: "run-1:user") + let echo = OpenClawChatMessage( + role: "user", + content: [Self.assistantText], + timestamp: 5678.5, + transcriptMessageID: "canonical-user-1", + idempotencyKey: "run-1:user") + + let messages = OpenClawChatViewModel.reconcileMessageIDs( + previous: [original], + incoming: [echo]) + + #expect(messages.count == 1) + #expect(messages.first?.id == original.id) + #expect(messages.first?.transcriptMessageID == "canonical-user-1") + } + + private static var assistantText: OpenClawChatMessageContent { + OpenClawChatMessageContent( + type: "text", + text: "agent reply", + mimeType: nil, + fileName: nil, + content: nil) + } + + private static func canonicalAssistantMessage( + timestamp: Double, + transcriptMessageID: String = "canonical-assistant-1") -> OpenClawChatMessage + { + OpenClawChatMessage( + role: "assistant", + content: [self.assistantText], + timestamp: timestamp, + transcriptMessageID: transcriptMessageID) + } + @Test func `maps sessions changed event to authoritative refresh signal`() { let payload = AnyCodable([ "sessionKey": AnyCodable("agent:main:main"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift index 6ec152fce86a..41620a9f293d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatGatewayPayloadCodec.swift @@ -138,6 +138,22 @@ public enum OpenClawChatGatewayPayloadCodec { payload, as: OpenClawSessionMessageEventPayload.self) else { return nil } + if var canonicalMessage = message.message, + canonicalMessage.transcriptMessageID? + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false, + let messageID = message.messageId?.trimmingCharacters(in: .whitespacesAndNewlines), + !messageID.isEmpty + { + // Live events carry durable transcript identity on their envelope. + // Preserve it on the row so history cannot replay the same message. + canonicalMessage.transcriptMessageID = messageID + return .sessionMessage(OpenClawSessionMessageEventPayload( + sessionKey: message.sessionKey, + agentId: message.agentId, + message: canonicalMessage, + messageId: message.messageId, + messageSeq: message.messageSeq)) + } return .sessionMessage(message) case "agent": guard let payload = frame.payload, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift index 08dedc173b78..8ac371108238 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift @@ -92,6 +92,9 @@ extension OpenClawChatViewModel { if let idempotencyKey = Self.normalizedIdempotencyKey(message.idempotencyKey) { return [role, "idempotency", idempotencyKey].joined(separator: "|") } + if let transcriptMessageID = Self.normalizedTranscriptMessageID(message.transcriptMessageID) { + return [role, "transcript", transcriptMessageID].joined(separator: "|") + } let timestamp: String = { guard let value = message.timestamp, value.isFinite else { return "" } @@ -146,6 +149,11 @@ extension OpenClawChatViewModel { return trimmed.isEmpty ? nil : trimmed } + private static func normalizedTranscriptMessageID(_ id: String?) -> String? { + let trimmed = id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + static func adoptingCanonicalMessage( _ incoming: OpenClawChatMessage, over existing: OpenClawChatMessage) -> OpenClawChatMessage @@ -157,6 +165,7 @@ extension OpenClawChatViewModel { in: incoming.content, from: existing.content), timestamp: incoming.timestamp ?? existing.timestamp, + transcriptMessageID: incoming.transcriptMessageID ?? existing.transcriptMessageID, idempotencyKey: incoming.idempotencyKey, toolCallId: incoming.toolCallId, toolName: incoming.toolName, @@ -696,6 +705,9 @@ extension OpenClawChatViewModel { if let idempotencyKey = normalizedIdempotencyKey(message.idempotencyKey) { return "\(message.role)|idempotency|\(idempotencyKey)" } + if let transcriptMessageID = normalizedTranscriptMessageID(message.transcriptMessageID) { + return "\(message.role)|transcript|\(transcriptMessageID)" + } guard let timestamp = message.timestamp else { return nil } let text = message.content.compactMap(\.text).joined(separator: "\n") .trimmingCharacters(in: .whitespacesAndNewlines)