Skip to content

Commit 5a5913a

Browse files
ooiuuiijoshavant
andauthored
fix(ios): avoid transient duplicate final replies (#98117)
* Fix iOS final reply dedupe * fix(ios): scope final message reconciliation * docs(ios): explain final message reconciliation key --------- Co-authored-by: joshavant <[email protected]>
1 parent 59df350 commit 5a5913a

2 files changed

Lines changed: 413 additions & 6 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ public final class OpenClawChatViewModel {
4747
}
4848

4949
private var pendingLocalUserEchoMessageIDsByRunID: [String: UUID] = [:]
50+
// Final chat events and durable session-message rows arrive independently.
51+
// Keep each provisional final scoped to the run's user turn so a later identical
52+
// answer in the same session does not adopt or suppress the wrong row.
53+
private var runMessageScopesByRunID: [String: RunMessageScope] = [:]
54+
private var provisionalFinalMessagesByID: [UUID: ProvisionalFinalMessage] = [:]
5055
private var sessionGeneration: UInt64 = 0
5156
private var bootstrapGeneration: UInt64 = 0
5257
// A newer same-session history request only invalidates older responses after it applies.
@@ -111,6 +116,17 @@ public final class OpenClawChatViewModel {
111116
var timestamp: Double?
112117
}
113118

119+
private struct RunMessageScope {
120+
var session: SessionSnapshot
121+
var latestUserTurn: LatestUserTurn?
122+
}
123+
124+
private struct ProvisionalFinalMessage {
125+
var reconciliationKey: String
126+
var runId: String?
127+
var scope: RunMessageScope
128+
}
129+
114130
private var pendingToolCallsById: [String: OpenClawChatPendingToolCall] = [:] {
115131
didSet {
116132
self.pendingToolCalls = self.pendingToolCallsById.values
@@ -359,6 +375,8 @@ public final class OpenClawChatViewModel {
359375
Self.reconcileMessageIDs(previous: self.messages, incoming: incoming)
360376
}
361377
self.prunePendingLocalUserEchoMessageIDs()
378+
self.pruneProvisionalFinalMessages()
379+
self.pruneRunMessageScopes()
362380
self.sessionId = payload.sessionId
363381
// Incomplete refreshes can arrive before durable assistant history.
364382
// The latest visible user turn must survive answered before it can reject older replies.
@@ -526,6 +544,14 @@ public final class OpenClawChatViewModel {
526544
}.joined(separator: "\\u{001E}")
527545
}
528546

547+
private static func finalMessageContentFingerprint(for message: OpenClawChatMessage) -> String {
548+
message.content.map { item in
549+
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
550+
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
551+
return [type, text].joined(separator: "\\u{001F}")
552+
}.joined(separator: "\\u{001E}")
553+
}
554+
529555
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
530556
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
531557
guard !role.isEmpty else { return nil }
@@ -557,6 +583,104 @@ public final class OpenClawChatViewModel {
557583
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
558584
}
559585

586+
private static func finalMessageReconciliationKey(for message: OpenClawChatMessage) -> String? {
587+
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
588+
guard role == "assistant" else { return nil }
589+
590+
// chat.final and session.message can serialize the same final row with
591+
// different timestamps/content ids; the run user-turn scope owns safety
592+
// for repeated same-text replies across turns.
593+
let contentFingerprint = Self.finalMessageContentFingerprint(for: message)
594+
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
595+
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
596+
if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
597+
return nil
598+
}
599+
return [role, toolCallId, toolName, contentFingerprint].joined(separator: "|")
600+
}
601+
602+
private static func normalizedRunID(_ runId: String?) -> String? {
603+
let trimmed = runId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
604+
return trimmed.isEmpty ? nil : trimmed
605+
}
606+
607+
private func currentRunMessageScope() -> RunMessageScope {
608+
RunMessageScope(
609+
session: self.currentSessionSnapshot(),
610+
latestUserTurn: Self.latestUserTurn(in: self.messages))
611+
}
612+
613+
private func runMessageScope(for runId: String?) -> RunMessageScope {
614+
guard let runId = Self.normalizedRunID(runId),
615+
let scope = self.runMessageScopesByRunID[runId],
616+
self.isCurrentSession(scope.session)
617+
else {
618+
return self.currentRunMessageScope()
619+
}
620+
return scope
621+
}
622+
623+
private static func isSameUserTurnBoundary(_ lhs: LatestUserTurn?, _ rhs: LatestUserTurn?) -> Bool {
624+
switch (lhs, rhs) {
625+
case (nil, nil):
626+
return true
627+
case let (lhs?, rhs?):
628+
if let lhsKey = lhs.refreshKey, let rhsKey = rhs.refreshKey {
629+
return lhsKey == rhsKey && lhs.occurrence == rhs.occurrence
630+
}
631+
return lhs.refreshKey == nil &&
632+
rhs.refreshKey == nil &&
633+
lhs.occurrence == rhs.occurrence &&
634+
lhs.timestamp == rhs.timestamp
635+
default:
636+
return false
637+
}
638+
}
639+
640+
private static func indexAfterLatestUserTurn(
641+
_ latestUserTurn: LatestUserTurn?,
642+
in messages: [OpenClawChatMessage])
643+
-> [OpenClawChatMessage].Index
644+
{
645+
guard let latestUserTurn else { return messages.startIndex }
646+
if let refreshKey = latestUserTurn.refreshKey {
647+
var occurrence = 0
648+
for index in messages.indices {
649+
guard self.userRefreshIdentityKey(for: messages[index]) == refreshKey else { continue }
650+
occurrence += 1
651+
if occurrence == latestUserTurn.occurrence {
652+
return messages.index(after: index)
653+
}
654+
}
655+
} else if let timestamp = latestUserTurn.timestamp,
656+
let index = messages.lastIndex(where: { message in
657+
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user" &&
658+
message.timestamp == timestamp
659+
})
660+
{
661+
return messages.index(after: index)
662+
}
663+
664+
return messages.lastIndex(where: { message in
665+
message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
666+
}).map { messages.index(after: $0) } ?? messages.startIndex
667+
}
668+
669+
private func hasCanonicalFinalMessageMatching(
670+
_ message: OpenClawChatMessage,
671+
scope: RunMessageScope) -> Bool
672+
{
673+
guard let key = Self.finalMessageReconciliationKey(for: message) else { return false }
674+
guard self.isCurrentSession(scope.session) else { return false }
675+
let searchStart = Self.indexAfterLatestUserTurn(scope.latestUserTurn, in: self.messages)
676+
guard searchStart < self.messages.endIndex else { return false }
677+
678+
return self.messages[searchStart...].contains { existing in
679+
self.provisionalFinalMessagesByID[existing.id] == nil &&
680+
Self.finalMessageReconciliationKey(for: existing) == key
681+
}
682+
}
683+
560684
private func prunePendingLocalUserEchoMessageIDs() {
561685
guard !self.pendingLocalUserEchoMessageIDsByRunID.isEmpty else { return }
562686
let visibleMessageIDs = Set(messages.map(\.id))
@@ -565,6 +689,26 @@ public final class OpenClawChatViewModel {
565689
}
566690
}
567691

692+
private func pruneProvisionalFinalMessages() {
693+
guard !self.provisionalFinalMessagesByID.isEmpty else { return }
694+
let visibleMessageIDs = Set(messages.map(\.id))
695+
self.provisionalFinalMessagesByID = self.provisionalFinalMessagesByID.filter { entry in
696+
visibleMessageIDs.contains(entry.key) && self.isCurrentSession(entry.value.scope.session)
697+
}
698+
}
699+
700+
private func pruneRunMessageScopes() {
701+
self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in
702+
self.isCurrentSession(entry.value.session)
703+
}
704+
guard self.runMessageScopesByRunID.count > 64 else { return }
705+
let referencedRunIDs = Set(self.pendingRuns)
706+
.union(self.provisionalFinalMessagesByID.values.compactMap(\.runId))
707+
self.runMessageScopesByRunID = self.runMessageScopesByRunID.filter { entry in
708+
referencedRunIDs.contains(entry.key)
709+
}
710+
}
711+
568712
private func adoptPendingLocalUserEcho(incoming: OpenClawChatMessage) -> Bool {
569713
guard let incomingKey = Self.userRefreshIdentityKey(for: incoming) else { return false }
570714
guard let matchIndex = messages.lastIndex(where: { existing in
@@ -594,6 +738,43 @@ public final class OpenClawChatViewModel {
594738
return true
595739
}
596740

741+
private func adoptProvisionalFinalMessage(incoming: OpenClawChatMessage) -> Bool {
742+
guard let incomingKey = Self.finalMessageReconciliationKey(for: incoming) else { return false }
743+
let canonicalScope = self.currentRunMessageScope()
744+
let searchStart = Self.indexAfterLatestUserTurn(canonicalScope.latestUserTurn, in: self.messages)
745+
guard searchStart < self.messages.endIndex else { return false }
746+
747+
guard let matchIndex = messages[searchStart...].lastIndex(where: { existing in
748+
guard let provisional = self.provisionalFinalMessagesByID[existing.id] else { return false }
749+
return provisional.reconciliationKey == incomingKey &&
750+
Self.isSameUserTurnBoundary(provisional.scope.latestUserTurn, canonicalScope.latestUserTurn)
751+
}) else {
752+
return false
753+
}
754+
755+
let existing = self.messages[matchIndex]
756+
let provisional = self.provisionalFinalMessagesByID[existing.id]
757+
var updated = self.messages
758+
updated[matchIndex] = OpenClawChatMessage(
759+
id: existing.id,
760+
role: incoming.role,
761+
content: incoming.content,
762+
timestamp: incoming.timestamp ?? existing.timestamp,
763+
toolCallId: incoming.toolCallId,
764+
toolName: incoming.toolName,
765+
usage: incoming.usage,
766+
stopReason: incoming.stopReason,
767+
errorMessage: incoming.errorMessage)
768+
self.provisionalFinalMessagesByID.removeValue(forKey: existing.id)
769+
if let runId = provisional?.runId {
770+
self.runMessageScopesByRunID.removeValue(forKey: runId)
771+
}
772+
self.messages = Self.dedupeMessages(updated)
773+
self.pruneProvisionalFinalMessages()
774+
self.pruneRunMessageScopes()
775+
return true
776+
}
777+
597778
private static func reconcileMessageIDs(
598779
previous: [OpenClawChatMessage],
599780
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
@@ -840,6 +1021,7 @@ public final class OpenClawChatViewModel {
8401021
content: userContent,
8411022
timestamp: userMessageTimestamp))
8421023
self.pendingLocalUserEchoMessageIDsByRunID[runId] = userMessageID
1024+
self.runMessageScopesByRunID[runId] = self.currentRunMessageScope()
8431025

8441026
// Clear input immediately for responsive UX (before network await)
8451027
self.input = ""
@@ -863,9 +1045,11 @@ public final class OpenClawChatViewModel {
8631045
+ "localRunId=\(runId) remoteRunId=\(response.runId)")
8641046
if response.runId != runId {
8651047
let pendingUserMessageID = self.pendingLocalUserEchoMessageIDsByRunID.removeValue(forKey: runId)
1048+
let runScope = self.runMessageScopesByRunID.removeValue(forKey: runId)
8661049
self.clearPendingRun(runId)
8671050
self.pendingRuns.insert(response.runId)
8681051
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
1052+
self.runMessageScopesByRunID[response.runId] = runScope
8691053
self.armPendingRunTimeout(runId: response.runId)
8701054
}
8711055
if response.status == "ok" {
@@ -894,6 +1078,7 @@ public final class OpenClawChatViewModel {
8941078
} catch {
8951079
guard self.isCurrentSession(sessionSnapshot) else { return }
8961080
self.removePendingLocalUserEcho(for: runId)
1081+
self.runMessageScopesByRunID.removeValue(forKey: runId)
8971082
self.clearPendingRun(runId)
8981083
self.errorText = error.localizedDescription
8991084
self.logDiagnostic(
@@ -955,6 +1140,8 @@ public final class OpenClawChatViewModel {
9551140
self.modelSelectionID = Self.defaultModelSelectionID
9561141
self.messages = []
9571142
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
1143+
self.runMessageScopesByRunID.removeAll()
1144+
self.provisionalFinalMessagesByID.removeAll()
9581145
self.sessionId = nil
9591146
self.pendingToolCallsById = [:]
9601147
self.streamingAssistantText = nil
@@ -989,6 +1176,8 @@ public final class OpenClawChatViewModel {
9891176
self.modelSelectionID = Self.defaultModelSelectionID
9901177
self.messages = []
9911178
self.pendingLocalUserEchoMessageIDsByRunID.removeAll()
1179+
self.runMessageScopesByRunID.removeAll()
1180+
self.provisionalFinalMessagesByID.removeAll()
9921181
self.sessionId = nil
9931182
self.pendingToolCallsById = [:]
9941183
self.streamingAssistantText = nil
@@ -1016,6 +1205,8 @@ public final class OpenClawChatViewModel {
10161205
return
10171206
}
10181207

1208+
self.runMessageScopesByRunID.removeAll()
1209+
self.provisionalFinalMessagesByID.removeAll()
10191210
self.startBootstrap()
10201211
}
10211212

@@ -1519,9 +1710,14 @@ public final class OpenClawChatViewModel {
15191710
if self.adoptPendingLocalUserEcho(incoming: sanitized) {
15201711
return
15211712
}
1713+
if self.adoptProvisionalFinalMessage(incoming: sanitized) {
1714+
return
1715+
}
15221716

15231717
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [sanitized])
15241718
self.messages = Self.dedupeMessages(reconciled)
1719+
self.pruneProvisionalFinalMessages()
1720+
self.pruneRunMessageScopes()
15251721
}
15261722

15271723
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
@@ -1608,8 +1804,28 @@ public final class OpenClawChatViewModel {
16081804
stopReason: "stop")
16091805
}
16101806

1807+
let runId = Self.normalizedRunID(chat.runId)
1808+
let scope = self.runMessageScope(for: runId)
1809+
guard self.isCurrentSession(scope.session) else { return }
1810+
guard let reconciliationKey = Self.finalMessageReconciliationKey(for: message) else { return }
1811+
1812+
if self.hasCanonicalFinalMessageMatching(message, scope: scope) {
1813+
if let runId {
1814+
self.runMessageScopesByRunID.removeValue(forKey: runId)
1815+
}
1816+
return
1817+
}
1818+
16111819
let reconciled = Self.reconcileMessageIDs(previous: self.messages, incoming: self.messages + [message])
16121820
self.messages = Self.dedupeMessages(reconciled)
1821+
if self.messages.contains(where: { $0.id == message.id }) {
1822+
self.provisionalFinalMessagesByID[message.id] = ProvisionalFinalMessage(
1823+
reconciliationKey: reconciliationKey,
1824+
runId: runId,
1825+
scope: scope)
1826+
}
1827+
self.pruneProvisionalFinalMessages()
1828+
self.pruneRunMessageScopes()
16131829
}
16141830

16151831
private static func isAssistantMessage(_ message: OpenClawChatMessage) -> Bool {
@@ -1761,7 +1977,7 @@ public final class OpenClawChatViewModel {
17611977
}
17621978

17631979
private func removePendingLocalUserEcho(for runId: String) {
1764-
guard let messageID = self.pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
1980+
guard let messageID = pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
17651981
self.messages.removeAll { $0.id == messageID }
17661982
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
17671983
}
@@ -1852,7 +2068,7 @@ public final class OpenClawChatViewModel {
18522068
guard let lastUserIndex = messages.lastIndex(where: { $0.role.lowercased() == "user" }) else {
18532069
return nil
18542070
}
1855-
guard let refreshKey = self.userRefreshIdentityKey(for: messages[lastUserIndex]) else {
2071+
guard let refreshKey = userRefreshIdentityKey(for: messages[lastUserIndex]) else {
18562072
return LatestUserTurn(
18572073
refreshKey: nil,
18582074
occurrence: 0,

0 commit comments

Comments
 (0)