@@ -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