mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 20:13:59 +00:00
* fix(ios): restore in-flight chat runs from gateway history Restore active Apple chat ownership across reconnect, foreground, and sequence-gap recovery using the existing chat.history snapshot. Preserve agent/session scoping and Gateway user-turn identity across Codex and Copilot mirrors, including current offline-cache integration. * fix(ios): restore in-flight chat runs from gateway history
639 lines
28 KiB
Swift
639 lines
28 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
|
|
extension OpenClawChatViewModel {
|
|
static func decodeMessages(_ raw: [AnyCodable]) -> [OpenClawChatMessage] {
|
|
let decoded = raw.compactMap { item in
|
|
(try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self))
|
|
.map { Self.stripInboundMetadata(from: $0) }
|
|
}
|
|
return Self.dedupeMessages(decoded)
|
|
}
|
|
|
|
static func stripInboundMetadata(from message: OpenClawChatMessage) -> OpenClawChatMessage {
|
|
guard message.role.lowercased() == "user" else {
|
|
return message
|
|
}
|
|
|
|
let sanitizedContent = message.content.map { content -> OpenClawChatMessageContent in
|
|
guard let text = content.text else { return content }
|
|
let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned
|
|
return OpenClawChatMessageContent(
|
|
type: content.type,
|
|
text: cleaned,
|
|
thinking: content.thinking,
|
|
thinkingSignature: content.thinkingSignature,
|
|
mimeType: content.mimeType,
|
|
fileName: content.fileName,
|
|
content: content.content,
|
|
id: content.id,
|
|
name: content.name,
|
|
arguments: content.arguments)
|
|
}
|
|
|
|
return OpenClawChatMessage(
|
|
id: message.id,
|
|
role: message.role,
|
|
content: sanitizedContent,
|
|
timestamp: message.timestamp,
|
|
idempotencyKey: message.idempotencyKey,
|
|
toolCallId: message.toolCallId,
|
|
toolName: message.toolName,
|
|
usage: message.usage,
|
|
stopReason: message.stopReason,
|
|
errorMessage: message.errorMessage)
|
|
}
|
|
|
|
static func messageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
|
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}")
|
|
}
|
|
|
|
static func finalMessageContentFingerprint(for message: OpenClawChatMessage) -> String {
|
|
message.content.map { item in
|
|
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return [type, text].joined(separator: "\\u{001F}")
|
|
}.joined(separator: "\\u{001E}")
|
|
}
|
|
|
|
static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !role.isEmpty else { return nil }
|
|
|
|
// The gateway persists this key with the canonical user row. Prefer it
|
|
// so a server timestamp change cannot replace the optimistic row's ID.
|
|
if let idempotencyKey = Self.normalizedIdempotencyKey(message.idempotencyKey) {
|
|
return [role, "idempotency", idempotencyKey].joined(separator: "|")
|
|
}
|
|
|
|
let timestamp: String = {
|
|
guard let value = message.timestamp, value.isFinite else { return "" }
|
|
return String(format: "%.3f", value)
|
|
}()
|
|
|
|
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
|
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: "|")
|
|
}
|
|
|
|
static func userRefreshIdentityKey(for message: OpenClawChatMessage) -> String? {
|
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard role == "user" else { return nil }
|
|
|
|
let contentFingerprint = Self.messageContentFingerprint(for: message)
|
|
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
|
return nil
|
|
}
|
|
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
|
}
|
|
|
|
static func finalMessageReconciliationKey(for message: OpenClawChatMessage) -> String? {
|
|
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard role == "assistant" else { return nil }
|
|
|
|
// chat.final and session.message can serialize the same final row with
|
|
// different timestamps/content ids. Reconciliation prefers the durable
|
|
// gateway run key, with user-turn scope as a legacy fallback.
|
|
let contentFingerprint = Self.finalMessageContentFingerprint(for: message)
|
|
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
|
return nil
|
|
}
|
|
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
|
}
|
|
|
|
static func normalizedRunID(_ runId: String?) -> String? {
|
|
let trimmed = runId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func normalizedIdempotencyKey(_ key: String?) -> String? {
|
|
let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func adoptingCanonicalMessage(
|
|
_ incoming: OpenClawChatMessage,
|
|
over existing: OpenClawChatMessage) -> OpenClawChatMessage
|
|
{
|
|
OpenClawChatMessage(
|
|
id: existing.id,
|
|
role: incoming.role,
|
|
content: incoming.content,
|
|
timestamp: incoming.timestamp ?? existing.timestamp,
|
|
idempotencyKey: incoming.idempotencyKey,
|
|
toolCallId: incoming.toolCallId,
|
|
toolName: incoming.toolName,
|
|
usage: incoming.usage,
|
|
stopReason: incoming.stopReason,
|
|
errorMessage: incoming.errorMessage)
|
|
}
|
|
|
|
func currentRunMessageScope() -> RunMessageScope {
|
|
RunMessageScope(
|
|
session: self.currentSessionSnapshot(),
|
|
latestUserTurn: Self.latestUserTurn(in: self.messages))
|
|
}
|
|
|
|
func runMessageScope(for runId: String?) -> RunMessageScope {
|
|
guard let runId = Self.normalizedRunID(runId),
|
|
let scope = self.runMessageScopesByRunID[runId],
|
|
self.isCurrentSession(scope.session)
|
|
else {
|
|
return self.currentRunMessageScope()
|
|
}
|
|
return scope
|
|
}
|
|
|
|
static func isSameUserTurnBoundary(_ lhs: LatestUserTurn?, _ rhs: LatestUserTurn?) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (nil, nil):
|
|
return true
|
|
case let (lhs?, rhs?):
|
|
if let lhsIdempotencyKey = lhs.idempotencyKey,
|
|
let rhsIdempotencyKey = rhs.idempotencyKey,
|
|
lhsIdempotencyKey == rhsIdempotencyKey
|
|
{
|
|
return true
|
|
}
|
|
if let lhsKey = lhs.refreshKey, let rhsKey = rhs.refreshKey {
|
|
return lhsKey == rhsKey && lhs.occurrence == rhs.occurrence
|
|
}
|
|
if lhs.idempotencyKey != nil || rhs.idempotencyKey != nil {
|
|
return false
|
|
}
|
|
return lhs.refreshKey == nil &&
|
|
rhs.refreshKey == nil &&
|
|
lhs.occurrence == rhs.occurrence &&
|
|
lhs.timestamp == rhs.timestamp
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
static func containsUserTurn(
|
|
_ latestUserTurn: LatestUserTurn?,
|
|
in messages: [OpenClawChatMessage]) -> Bool
|
|
{
|
|
guard let latestUserTurn else { return false }
|
|
if let idempotencyKey = latestUserTurn.idempotencyKey {
|
|
return messages.contains { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey
|
|
}
|
|
}
|
|
if let refreshKey = latestUserTurn.refreshKey {
|
|
let occurrenceCount = messages.count { Self.userRefreshIdentityKey(for: $0) == refreshKey }
|
|
return occurrenceCount >= latestUserTurn.occurrence
|
|
}
|
|
guard let timestamp = latestUserTurn.timestamp else { return false }
|
|
return messages.contains { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
message.timestamp == timestamp
|
|
}
|
|
}
|
|
|
|
static func indexAfterLatestUserTurn(
|
|
_ latestUserTurn: LatestUserTurn?,
|
|
in messages: [OpenClawChatMessage])
|
|
-> [OpenClawChatMessage].Index
|
|
{
|
|
guard let latestUserTurn else { return messages.startIndex }
|
|
if let idempotencyKey = latestUserTurn.idempotencyKey,
|
|
let index = messages.lastIndex(where: { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey) == idempotencyKey
|
|
})
|
|
{
|
|
return messages.index(after: index)
|
|
} else if let refreshKey = latestUserTurn.refreshKey {
|
|
var occurrence = 0
|
|
for index in messages.indices {
|
|
guard self.userRefreshIdentityKey(for: messages[index]) == refreshKey else { continue }
|
|
occurrence += 1
|
|
if occurrence == latestUserTurn.occurrence {
|
|
return messages.index(after: index)
|
|
}
|
|
}
|
|
} else if let timestamp = latestUserTurn.timestamp,
|
|
let index = messages.lastIndex(where: { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
message.timestamp == timestamp
|
|
})
|
|
{
|
|
return messages.index(after: index)
|
|
}
|
|
|
|
return messages.lastIndex(where: { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
|
}).map { messages.index(after: $0) } ?? messages.startIndex
|
|
}
|
|
|
|
static func messageRange(
|
|
after latestUserTurn: LatestUserTurn?,
|
|
in messages: [OpenClawChatMessage]) -> Range<[OpenClawChatMessage].Index>
|
|
{
|
|
let start = self.indexAfterLatestUserTurn(latestUserTurn, in: messages)
|
|
guard latestUserTurn != nil, start < messages.endIndex else {
|
|
return start..<messages.endIndex
|
|
}
|
|
// Without an exact assistant run key, any user row is a turn boundary.
|
|
// Steering and channel-originated turns are both metadata-free, so
|
|
// widening this range would make distinct replies indistinguishable.
|
|
let end = messages[start...].firstIndex { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
|
|
} ?? messages.endIndex
|
|
return start..<end
|
|
}
|
|
|
|
func hasCanonicalFinalMessageMatching(
|
|
_ message: OpenClawChatMessage,
|
|
scope: RunMessageScope) -> Bool
|
|
{
|
|
guard let key = Self.finalMessageReconciliationKey(for: message) else { return false }
|
|
guard self.isCurrentSession(scope.session) else { return false }
|
|
if let runId = Self.normalizedIdempotencyKey(message.idempotencyKey) {
|
|
return self.messages.contains { existing in
|
|
self.provisionalFinalMessagesByID[existing.id] == nil &&
|
|
Self.normalizedIdempotencyKey(existing.idempotencyKey) == runId
|
|
}
|
|
}
|
|
let searchRange = Self.messageRange(after: scope.latestUserTurn, in: self.messages)
|
|
guard !searchRange.isEmpty else { return false }
|
|
|
|
return self.messages[searchRange].contains { existing in
|
|
self.provisionalFinalMessagesByID[existing.id] == nil &&
|
|
Self.finalMessageReconciliationKey(for: existing) == key
|
|
}
|
|
}
|
|
|
|
func adoptingProvisionalFinalMessageIDs(
|
|
in incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
|
{
|
|
guard !self.provisionalFinalMessagesByID.isEmpty, !incoming.isEmpty else { return incoming }
|
|
var reconciled = incoming
|
|
var claimedIncomingIndices = Set<Int>()
|
|
|
|
// Provider-owned transcripts may replace the gateway run id with their
|
|
// own durable idempotency key. Preserve the provisional row's UI identity
|
|
// by falling back to content within the same user-turn boundary.
|
|
for existing in self.messages {
|
|
guard let provisional = self.provisionalFinalMessagesByID[existing.id] else { continue }
|
|
let exactRunIndex = provisional.runId.flatMap { runId in
|
|
reconciled.indices.last { index in
|
|
!claimedIncomingIndices.contains(index) &&
|
|
Self.isAssistantMessage(reconciled[index]) &&
|
|
Self.normalizedIdempotencyKey(reconciled[index].idempotencyKey) == runId
|
|
}
|
|
}
|
|
let matchingIndex = exactRunIndex ?? {
|
|
guard Self.containsUserTurn(provisional.scope.latestUserTurn, in: reconciled) else {
|
|
return nil
|
|
}
|
|
let range = Self.messageRange(after: provisional.scope.latestUserTurn, in: reconciled)
|
|
return range.last { index in
|
|
!claimedIncomingIndices.contains(index) &&
|
|
Self.finalMessageReconciliationKey(for: reconciled[index]) == provisional.reconciliationKey
|
|
}
|
|
}()
|
|
guard let matchingIndex else { continue }
|
|
claimedIncomingIndices.insert(matchingIndex)
|
|
reconciled[matchingIndex] = Self.adoptingCanonicalMessage(
|
|
reconciled[matchingIndex],
|
|
over: existing)
|
|
}
|
|
return reconciled
|
|
}
|
|
|
|
func prunePendingLocalUserEchoMessageIDs() {
|
|
guard !self.pendingLocalUserEchoMessageIDsByRunID.isEmpty else { return }
|
|
let visibleMessageIDs = Set(messages.map(\.id))
|
|
self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter {
|
|
self.pendingRuns.contains($0.key) && visibleMessageIDs.contains($0.value)
|
|
}
|
|
}
|
|
|
|
func pruneProvisionalFinalMessages() {
|
|
guard !self.provisionalFinalMessagesByID.isEmpty else { return }
|
|
let visibleMessageIDs = Set(messages.map(\.id))
|
|
self.provisionalFinalMessagesByID = self.provisionalFinalMessagesByID.filter { entry in
|
|
visibleMessageIDs.contains(entry.key) && self.isCurrentSession(entry.value.scope.session)
|
|
}
|
|
}
|
|
|
|
func clearProvisionalFinalMarkersAdoptedByHistory(_ incoming: [OpenClawChatMessage]) {
|
|
let adoptedMessageIDs = Set(incoming.map(\.id))
|
|
self.provisionalFinalMessagesByID = self.provisionalFinalMessagesByID.filter {
|
|
!adoptedMessageIDs.contains($0.key)
|
|
}
|
|
}
|
|
|
|
func pruneRunMessageScopes() {
|
|
self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in
|
|
self.isCurrentSession(entry.value.session)
|
|
}
|
|
guard self.runMessageScopesByRunID.count > 64 else { return }
|
|
let referencedRunIDs = Set(self.pendingRuns)
|
|
.union(self.provisionalFinalMessagesByID.values.compactMap(\.runId))
|
|
self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in
|
|
referencedRunIDs.contains(entry.key)
|
|
}
|
|
}
|
|
|
|
func adoptCorrelatedUserMessage(incoming: OpenClawChatMessage) -> Bool {
|
|
guard incoming.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" else {
|
|
return false
|
|
}
|
|
// The final event can clear pending bookkeeping before session.message
|
|
// arrives. The persisted key still identifies the exact user turn, so
|
|
// adopt the durable row without losing the optimistic row's UI identity.
|
|
guard let incomingKey = Self.normalizedIdempotencyKey(incoming.idempotencyKey) else { return false }
|
|
let matchIndex = self.messages.lastIndex { existing in
|
|
existing.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
Self.normalizedIdempotencyKey(existing.idempotencyKey) == incomingKey
|
|
}
|
|
guard let matchIndex else {
|
|
return false
|
|
}
|
|
|
|
let existing = self.messages[matchIndex]
|
|
self.pendingLocalUserEchoMessageIDsByRunID = self.pendingLocalUserEchoMessageIDsByRunID.filter {
|
|
$0.value != existing.id
|
|
}
|
|
var updated = self.messages
|
|
updated[matchIndex] = Self.adoptingCanonicalMessage(incoming, over: existing)
|
|
self.replaceMessages(Self.dedupeMessages(updated))
|
|
self.prunePendingLocalUserEchoMessageIDs()
|
|
return true
|
|
}
|
|
|
|
func rekeyLocalUserEcho(
|
|
messageID: UUID?,
|
|
runId: String) -> (pendingMessageID: UUID?, scope: RunMessageScope)?
|
|
{
|
|
guard let messageID, let matchIndex = self.messages.firstIndex(where: { $0.id == messageID }) else {
|
|
return nil
|
|
}
|
|
let existing = self.messages[matchIndex]
|
|
let remoteKey = "\(runId):user"
|
|
let canonical = self.messages.last { message in
|
|
message.id != messageID &&
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey
|
|
}
|
|
var updated = self.messages
|
|
updated[matchIndex] = if let canonical {
|
|
Self.adoptingCanonicalMessage(canonical, over: existing)
|
|
} else {
|
|
OpenClawChatMessage(
|
|
id: existing.id,
|
|
role: existing.role,
|
|
content: existing.content,
|
|
timestamp: existing.timestamp,
|
|
idempotencyKey: remoteKey,
|
|
toolCallId: existing.toolCallId,
|
|
toolName: existing.toolName,
|
|
usage: existing.usage,
|
|
stopReason: existing.stopReason,
|
|
errorMessage: existing.errorMessage)
|
|
}
|
|
self.replaceMessages(Self.dedupeMessages(updated))
|
|
guard let survivingIndex = self.messages.firstIndex(where: { message in
|
|
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey) == remoteKey
|
|
}) else {
|
|
return nil
|
|
}
|
|
let survivingMessage = self.messages[survivingIndex]
|
|
let scope = RunMessageScope(
|
|
session: self.currentSessionSnapshot(),
|
|
latestUserTurn: Self.userTurn(at: survivingIndex, in: self.messages))
|
|
let pendingMessageID = survivingMessage.id == messageID ? messageID : nil
|
|
return (pendingMessageID, scope)
|
|
}
|
|
|
|
func rescopeProvisionalFinalMessages(runId: String, scope: RunMessageScope) {
|
|
let matchingMessageIDs = self.provisionalFinalMessagesByID.compactMap { messageID, provisional in
|
|
provisional.runId == runId ? messageID : nil
|
|
}
|
|
for messageID in matchingMessageIDs {
|
|
self.provisionalFinalMessagesByID[messageID]?.scope = scope
|
|
}
|
|
}
|
|
|
|
func hasRecordedFinalMessage(runId: String) -> Bool {
|
|
if self.provisionalFinalMessagesByID.values.contains(where: { $0.runId == runId }) {
|
|
return true
|
|
}
|
|
return self.messages.contains { message in
|
|
Self.isAssistantMessage(message) &&
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey) == runId
|
|
}
|
|
}
|
|
|
|
func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool {
|
|
let incomingRunId = Self.normalizedIdempotencyKey(incoming.idempotencyKey)
|
|
let incomingKey = Self.finalMessageReconciliationKey(for: incoming)
|
|
guard incomingRunId != nil || incomingKey != nil else { return false }
|
|
let canonicalUserTurn = self.currentRunMessageScope().latestUserTurn
|
|
guard let matchIndex = messages.indices.last(where: { index in
|
|
let existing = self.messages[index]
|
|
guard let provisional = self.provisionalFinalMessagesByID[existing.id] else { return false }
|
|
if let incomingRunId, provisional.runId == incomingRunId {
|
|
return true
|
|
}
|
|
return provisional.reconciliationKey == incomingKey &&
|
|
Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalUserTurn)
|
|
}) else {
|
|
return false
|
|
}
|
|
|
|
let existing = self.messages[matchIndex]
|
|
let provisional = self.provisionalFinalMessagesByID[existing.id]
|
|
var updated = self.messages
|
|
updated.remove(at: matchIndex)
|
|
// The durable session.message arrives at its transcript position. A
|
|
// steering row may have been delivered after chat.final, so append the
|
|
// adopted row here while retaining the provisional UUID.
|
|
updated.append(Self.adoptingCanonicalMessage(incoming, over: existing))
|
|
self.provisionalFinalMessagesByID.removeValue(forKey: existing.id)
|
|
if let runId = provisional?.runId {
|
|
self.runMessageScopesByRunID.removeValue(forKey: runId)
|
|
}
|
|
self.replaceMessages(Self.dedupeMessages(updated))
|
|
self.pruneProvisionalFinalMessages()
|
|
self.pruneRunMessageScopes()
|
|
return true
|
|
}
|
|
|
|
static func reconcileMessageIDs(
|
|
previous: [OpenClawChatMessage],
|
|
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
|
{
|
|
guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
|
|
|
|
var previousMessagesByKey: [String: [OpenClawChatMessage]] = [:]
|
|
for message in previous {
|
|
guard let key = Self.messageIdentityKey(for: message) else { continue }
|
|
previousMessagesByKey[key, default: []].append(message)
|
|
}
|
|
|
|
return incoming.map { message in
|
|
guard let key = Self.messageIdentityKey(for: message),
|
|
var matches = previousMessagesByKey[key],
|
|
let existing = matches.first
|
|
else {
|
|
return message
|
|
}
|
|
matches.removeFirst()
|
|
if matches.isEmpty {
|
|
previousMessagesByKey.removeValue(forKey: key)
|
|
} else {
|
|
previousMessagesByKey[key] = matches
|
|
}
|
|
guard existing.id != message.id else { return message }
|
|
return Self.adoptingCanonicalMessage(message, over: existing)
|
|
}
|
|
}
|
|
|
|
static func reconcileRunRefreshMessages(
|
|
previous: [OpenClawChatMessage],
|
|
incoming: [OpenClawChatMessage],
|
|
pendingLocalUserEchoIDs: Set<UUID>) -> [OpenClawChatMessage]
|
|
{
|
|
guard !previous.isEmpty else { return incoming }
|
|
guard !incoming.isEmpty else { return previous }
|
|
|
|
func countKeys(_ keys: [String]) -> [String: Int] {
|
|
keys.reduce(into: [:]) { counts, key in
|
|
counts[key, default: 0] += 1
|
|
}
|
|
}
|
|
|
|
var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming)
|
|
let incomingIdempotencyKeys = Set(reconciled.compactMap { message in
|
|
Self.normalizedIdempotencyKey(message.idempotencyKey)
|
|
})
|
|
let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:)))
|
|
var remainingIncomingUserRefreshCounts = countKeys(
|
|
reconciled.compactMap(Self.userRefreshIdentityKey(for:)))
|
|
|
|
// Exact history rows own their incoming user count before local echo matching.
|
|
// Otherwise repeated same-text sends can consume the canonical row twice.
|
|
for message in previous {
|
|
guard let identityKey = Self.messageIdentityKey(for: message),
|
|
incomingIdentityKeys.contains(identityKey),
|
|
let userKey = Self.userRefreshIdentityKey(for: message),
|
|
let remaining = remainingIncomingUserRefreshCounts[userKey],
|
|
remaining > 0
|
|
else {
|
|
continue
|
|
}
|
|
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
|
}
|
|
|
|
// Exact client correlation owns pending-send adoption. A metadata-free
|
|
// row is ambiguous across clients, so retain both rather than lose a turn.
|
|
let pendingLocalUsers = previous.filter { message in
|
|
guard message.role.lowercased() == "user", pendingLocalUserEchoIDs.contains(message.id) else {
|
|
return false
|
|
}
|
|
let matched = Self.normalizedIdempotencyKey(message.idempotencyKey)
|
|
.map(incomingIdempotencyKeys.contains) ?? false
|
|
guard matched else { return true }
|
|
if let userKey = Self.userRefreshIdentityKey(for: message),
|
|
let remaining = remainingIncomingUserRefreshCounts[userKey],
|
|
remaining > 0
|
|
{
|
|
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
let lastCanonicalPreviousIndex = previous.lastIndex { message in
|
|
guard let identityKey = Self.messageIdentityKey(for: message) else { return false }
|
|
return incomingIdentityKeys.contains(identityKey)
|
|
}
|
|
let trailingLocalCandidates = lastCanonicalPreviousIndex.map { index in
|
|
previous[previous.index(after: index)...]
|
|
} ?? []
|
|
|
|
let trailingLocalUsers = trailingLocalCandidates.filter { message in
|
|
guard message.role.lowercased() == "user" else { return false }
|
|
guard !pendingLocalUserEchoIDs.contains(message.id) else { return false }
|
|
guard let identityKey = Self.messageIdentityKey(for: message) else { return true }
|
|
guard !incomingIdentityKeys.contains(identityKey) else { return false }
|
|
guard let userKey = Self.userRefreshIdentityKey(for: message) else { return true }
|
|
let remaining = remainingIncomingUserRefreshCounts[userKey] ?? 0
|
|
if remaining > 0 {
|
|
remainingIncomingUserRefreshCounts[userKey] = remaining - 1
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
let optimisticUserMessages = pendingLocalUsers + trailingLocalUsers
|
|
|
|
guard !optimisticUserMessages.isEmpty else {
|
|
return reconciled
|
|
}
|
|
|
|
for message in optimisticUserMessages {
|
|
guard let messageTimestamp = message.timestamp else {
|
|
reconciled.append(message)
|
|
continue
|
|
}
|
|
|
|
let insertIndex = reconciled.firstIndex { existing in
|
|
guard let existingTimestamp = existing.timestamp else { return false }
|
|
return existingTimestamp > messageTimestamp
|
|
} ?? reconciled.endIndex
|
|
reconciled.insert(message, at: insertIndex)
|
|
}
|
|
|
|
return Self.dedupeMessages(reconciled)
|
|
}
|
|
|
|
static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
|
var result: [OpenClawChatMessage] = []
|
|
result.reserveCapacity(messages.count)
|
|
var seen = Set<String>()
|
|
|
|
for message in messages {
|
|
guard let key = Self.dedupeKey(for: message) else {
|
|
result.append(message)
|
|
continue
|
|
}
|
|
if seen.contains(key) { continue }
|
|
seen.insert(key)
|
|
result.append(message)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
static func dedupeKey(for message: OpenClawChatMessage) -> String? {
|
|
if let idempotencyKey = normalizedIdempotencyKey(message.idempotencyKey) {
|
|
return "\(message.role)|idempotency|\(idempotencyKey)"
|
|
}
|
|
guard let timestamp = message.timestamp else { return nil }
|
|
let text = message.content.compactMap(\.text).joined(separator: "\n")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { return nil }
|
|
return "\(message.role)|\(timestamp)|\(text)"
|
|
}
|
|
}
|