@@ -47,7 +47,11 @@ public final class OpenClawChatViewModel {
4747 }
4848
4949 private var pendingLocalUserEchoMessageIDsByRunID : [ String : UUID ] = [ : ]
50- private var provisionalFinalMessageIDs = Set < 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 ] = [ : ]
5155 private var sessionGeneration : UInt64 = 0
5256 private var bootstrapGeneration : UInt64 = 0
5357 // A newer same-session history request only invalidates older responses after it applies.
@@ -112,6 +116,17 @@ public final class OpenClawChatViewModel {
112116 var timestamp : Double ?
113117 }
114118
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+
115130 private var pendingToolCallsById : [ String : OpenClawChatPendingToolCall ] = [ : ] {
116131 didSet {
117132 self . pendingToolCalls = self . pendingToolCallsById. values
@@ -360,7 +375,8 @@ public final class OpenClawChatViewModel {
360375 Self . reconcileMessageIDs ( previous: self . messages, incoming: incoming)
361376 }
362377 self . prunePendingLocalUserEchoMessageIDs ( )
363- self . pruneProvisionalFinalMessageIDs ( )
378+ self . pruneProvisionalFinalMessages ( )
379+ self . pruneRunMessageScopes ( )
364380 self . sessionId = payload. sessionId
365381 // Incomplete refreshes can arrive before durable assistant history.
366382 // The latest visible user turn must survive answered before it can reject older replies.
@@ -580,14 +596,84 @@ public final class OpenClawChatViewModel {
580596 return [ role, toolCallId, toolName, contentFingerprint] . joined ( separator: " | " )
581597 }
582598
583- private func hasCanonicalFinalMessageMatching( _ message: OpenClawChatMessage ) -> Bool {
599+ private static func normalizedRunID( _ runId: String ? ) -> String ? {
600+ let trimmed = runId? . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
601+ return trimmed. isEmpty ? nil : trimmed
602+ }
603+
604+ private func currentRunMessageScope( ) -> RunMessageScope {
605+ RunMessageScope (
606+ session: self . currentSessionSnapshot ( ) ,
607+ latestUserTurn: Self . latestUserTurn ( in: self . messages) )
608+ }
609+
610+ private func runMessageScope( for runId: String ? ) -> RunMessageScope {
611+ guard let runId = Self . normalizedRunID ( runId) ,
612+ let scope = self . runMessageScopesByRunID [ runId] ,
613+ self . isCurrentSession ( scope. session)
614+ else {
615+ return self . currentRunMessageScope ( )
616+ }
617+ return scope
618+ }
619+
620+ private static func isSameUserTurnBoundary( _ lhs: LatestUserTurn ? , _ rhs: LatestUserTurn ? ) -> Bool {
621+ switch ( lhs, rhs) {
622+ case ( nil , nil ) :
623+ return true
624+ case let ( lhs? , rhs? ) :
625+ if let lhsKey = lhs. refreshKey, let rhsKey = rhs. refreshKey {
626+ return lhsKey == rhsKey && lhs. occurrence == rhs. occurrence
627+ }
628+ return lhs. refreshKey == nil &&
629+ rhs. refreshKey == nil &&
630+ lhs. occurrence == rhs. occurrence &&
631+ lhs. timestamp == rhs. timestamp
632+ default :
633+ return false
634+ }
635+ }
636+
637+ private static func indexAfterLatestUserTurn(
638+ _ latestUserTurn: LatestUserTurn ? ,
639+ in messages: [ OpenClawChatMessage ] )
640+ -> [ OpenClawChatMessage ] . Index
641+ {
642+ guard let latestUserTurn else { return messages. startIndex }
643+ if let refreshKey = latestUserTurn. refreshKey {
644+ var occurrence = 0
645+ for index in messages. indices {
646+ guard self . userRefreshIdentityKey ( for: messages [ index] ) == refreshKey else { continue }
647+ occurrence += 1
648+ if occurrence == latestUserTurn. occurrence {
649+ return messages. index ( after: index)
650+ }
651+ }
652+ } else if let timestamp = latestUserTurn. timestamp,
653+ let index = messages. lastIndex ( where: { message in
654+ message. role. trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( ) == " user " &&
655+ message. timestamp == timestamp
656+ } )
657+ {
658+ return messages. index ( after: index)
659+ }
660+
661+ return messages. lastIndex ( where: { message in
662+ message. role. trimmingCharacters ( in: . whitespacesAndNewlines) . lowercased ( ) == " user "
663+ } ) . map { messages. index ( after: $0) } ?? messages. startIndex
664+ }
665+
666+ private func hasCanonicalFinalMessageMatching(
667+ _ message: OpenClawChatMessage ,
668+ scope: RunMessageScope ) -> Bool
669+ {
584670 guard let key = Self . finalMessageReconciliationKey ( for: message) else { return false }
585- let searchStart = self . messages . lastIndex ( where : { existing in
586- existing . role . trimmingCharacters ( in: . whitespacesAndNewlines ) . lowercased ( ) == " user "
587- } ) . map { self . messages. index ( after : $0 ) } ?? self . messages . startIndex
671+ guard self . isCurrentSession ( scope . session ) else { return false }
672+ let searchStart = Self . indexAfterLatestUserTurn ( scope . latestUserTurn , in: self . messages )
673+ guard searchStart < self . messages. endIndex else { return false }
588674
589675 return self . messages [ searchStart... ] . contains { existing in
590- ! self . provisionalFinalMessageIDs . contains ( existing. id) &&
676+ self . provisionalFinalMessagesByID [ existing. id] == nil &&
591677 Self . finalMessageReconciliationKey ( for: existing) == key
592678 }
593679 }
@@ -600,10 +686,24 @@ public final class OpenClawChatViewModel {
600686 }
601687 }
602688
603- private func pruneProvisionalFinalMessageIDs ( ) {
604- guard !self . provisionalFinalMessageIDs . isEmpty else { return }
689+ private func pruneProvisionalFinalMessages ( ) {
690+ guard !self . provisionalFinalMessagesByID . isEmpty else { return }
605691 let visibleMessageIDs = Set ( messages. map ( \. id) )
606- self . provisionalFinalMessageIDs = self . provisionalFinalMessageIDs. intersection ( visibleMessageIDs)
692+ self . provisionalFinalMessagesByID = self . provisionalFinalMessagesByID. filter { entry in
693+ visibleMessageIDs. contains ( entry. key) && self . isCurrentSession ( entry. value. scope. session)
694+ }
695+ }
696+
697+ private func pruneRunMessageScopes( ) {
698+ self . runMessageScopesByRunID = self . runMessageScopesByRunID. filter { entry in
699+ self . isCurrentSession ( entry. value. session)
700+ }
701+ guard self . runMessageScopesByRunID. count > 64 else { return }
702+ let referencedRunIDs = Set ( self . pendingRuns)
703+ . union ( self . provisionalFinalMessagesByID. values. compactMap ( \. runId) )
704+ self . runMessageScopesByRunID = self . runMessageScopesByRunID. filter { entry in
705+ referencedRunIDs. contains ( entry. key)
706+ }
607707 }
608708
609709 private func adoptPendingLocalUserEcho( incoming: OpenClawChatMessage ) -> Bool {
@@ -637,18 +737,20 @@ public final class OpenClawChatViewModel {
637737
638738 private func adoptProvisionalFinalMessage( incoming: OpenClawChatMessage ) -> Bool {
639739 guard let incomingKey = Self . finalMessageReconciliationKey ( for: incoming) else { return false }
640- let searchStart = self . messages . lastIndex ( where : { existing in
641- existing . role . trimmingCharacters ( in: . whitespacesAndNewlines ) . lowercased ( ) == " user "
642- } ) . map { self . messages. index ( after : $0 ) } ?? self . messages . startIndex
740+ let canonicalScope = self . currentRunMessageScope ( )
741+ let searchStart = Self . indexAfterLatestUserTurn ( canonicalScope . latestUserTurn , in: self . messages )
742+ guard searchStart < self . messages. endIndex else { return false }
643743
644744 guard let matchIndex = messages [ searchStart... ] . lastIndex ( where: { existing in
645- self . provisionalFinalMessageIDs. contains ( existing. id) &&
646- Self . finalMessageReconciliationKey ( for: existing) == incomingKey
745+ guard let provisional = self . provisionalFinalMessagesByID [ existing. id] else { return false }
746+ return provisional. reconciliationKey == incomingKey &&
747+ Self . isSameUserTurnBoundary ( provisional. scope. latestUserTurn, canonicalScope. latestUserTurn)
647748 } ) else {
648749 return false
649750 }
650751
651752 let existing = self . messages [ matchIndex]
753+ let provisional = self . provisionalFinalMessagesByID [ existing. id]
652754 var updated = self . messages
653755 updated [ matchIndex] = OpenClawChatMessage (
654756 id: existing. id,
@@ -660,9 +762,13 @@ public final class OpenClawChatViewModel {
660762 usage: incoming. usage,
661763 stopReason: incoming. stopReason,
662764 errorMessage: incoming. errorMessage)
663- self . provisionalFinalMessageIDs. remove ( existing. id)
765+ self . provisionalFinalMessagesByID. removeValue ( forKey: existing. id)
766+ if let runId = provisional? . runId {
767+ self . runMessageScopesByRunID. removeValue ( forKey: runId)
768+ }
664769 self . messages = Self . dedupeMessages ( updated)
665- self . pruneProvisionalFinalMessageIDs ( )
770+ self . pruneProvisionalFinalMessages ( )
771+ self . pruneRunMessageScopes ( )
666772 return true
667773 }
668774
@@ -912,6 +1018,7 @@ public final class OpenClawChatViewModel {
9121018 content: userContent,
9131019 timestamp: userMessageTimestamp) )
9141020 self . pendingLocalUserEchoMessageIDsByRunID [ runId] = userMessageID
1021+ self . runMessageScopesByRunID [ runId] = self . currentRunMessageScope ( )
9151022
9161023 // Clear input immediately for responsive UX (before network await)
9171024 self . input = " "
@@ -935,9 +1042,11 @@ public final class OpenClawChatViewModel {
9351042 + " localRunId= \( runId) remoteRunId= \( response. runId) " )
9361043 if response. runId != runId {
9371044 let pendingUserMessageID = self . pendingLocalUserEchoMessageIDsByRunID. removeValue ( forKey: runId)
1045+ let runScope = self . runMessageScopesByRunID. removeValue ( forKey: runId)
9381046 self . clearPendingRun ( runId)
9391047 self . pendingRuns. insert ( response. runId)
9401048 self . pendingLocalUserEchoMessageIDsByRunID [ response. runId] = pendingUserMessageID
1049+ self . runMessageScopesByRunID [ response. runId] = runScope
9411050 self . armPendingRunTimeout ( runId: response. runId)
9421051 }
9431052 if response. status == " ok " {
@@ -966,6 +1075,7 @@ public final class OpenClawChatViewModel {
9661075 } catch {
9671076 guard self . isCurrentSession ( sessionSnapshot) else { return }
9681077 self . removePendingLocalUserEcho ( for: runId)
1078+ self . runMessageScopesByRunID. removeValue ( forKey: runId)
9691079 self . clearPendingRun ( runId)
9701080 self . errorText = error. localizedDescription
9711081 self . logDiagnostic (
@@ -1027,7 +1137,8 @@ public final class OpenClawChatViewModel {
10271137 self . modelSelectionID = Self . defaultModelSelectionID
10281138 self . messages = [ ]
10291139 self . pendingLocalUserEchoMessageIDsByRunID. removeAll ( )
1030- self . provisionalFinalMessageIDs. removeAll ( )
1140+ self . runMessageScopesByRunID. removeAll ( )
1141+ self . provisionalFinalMessagesByID. removeAll ( )
10311142 self . sessionId = nil
10321143 self . pendingToolCallsById = [ : ]
10331144 self . streamingAssistantText = nil
@@ -1062,7 +1173,8 @@ public final class OpenClawChatViewModel {
10621173 self . modelSelectionID = Self . defaultModelSelectionID
10631174 self . messages = [ ]
10641175 self . pendingLocalUserEchoMessageIDsByRunID. removeAll ( )
1065- self . provisionalFinalMessageIDs. removeAll ( )
1176+ self . runMessageScopesByRunID. removeAll ( )
1177+ self . provisionalFinalMessagesByID. removeAll ( )
10661178 self . sessionId = nil
10671179 self . pendingToolCallsById = [ : ]
10681180 self . streamingAssistantText = nil
@@ -1090,6 +1202,8 @@ public final class OpenClawChatViewModel {
10901202 return
10911203 }
10921204
1205+ self . runMessageScopesByRunID. removeAll ( )
1206+ self . provisionalFinalMessagesByID. removeAll ( )
10931207 self . startBootstrap ( )
10941208 }
10951209
@@ -1599,7 +1713,8 @@ public final class OpenClawChatViewModel {
15991713
16001714 let reconciled = Self . reconcileMessageIDs ( previous: self . messages, incoming: self . messages + [ sanitized] )
16011715 self . messages = Self . dedupeMessages ( reconciled)
1602- self . pruneProvisionalFinalMessageIDs ( )
1716+ self . pruneProvisionalFinalMessages ( )
1717+ self . pruneRunMessageScopes ( )
16031718 }
16041719
16051720 private func handleChatEvent( _ chat: OpenClawChatEventPayload ) {
@@ -1686,16 +1801,28 @@ public final class OpenClawChatViewModel {
16861801 stopReason: " stop " )
16871802 }
16881803
1689- if self . hasCanonicalFinalMessageMatching ( message) {
1804+ let runId = Self . normalizedRunID ( chat. runId)
1805+ let scope = self . runMessageScope ( for: runId)
1806+ guard self . isCurrentSession ( scope. session) else { return }
1807+ guard let reconciliationKey = Self . finalMessageReconciliationKey ( for: message) else { return }
1808+
1809+ if self . hasCanonicalFinalMessageMatching ( message, scope: scope) {
1810+ if let runId {
1811+ self . runMessageScopesByRunID. removeValue ( forKey: runId)
1812+ }
16901813 return
16911814 }
16921815
16931816 let reconciled = Self . reconcileMessageIDs ( previous: self . messages, incoming: self . messages + [ message] )
16941817 self . messages = Self . dedupeMessages ( reconciled)
16951818 if self . messages. contains ( where: { $0. id == message. id } ) {
1696- self . provisionalFinalMessageIDs. insert ( message. id)
1819+ self . provisionalFinalMessagesByID [ message. id] = ProvisionalFinalMessage (
1820+ reconciliationKey: reconciliationKey,
1821+ runId: runId,
1822+ scope: scope)
16971823 }
1698- self . pruneProvisionalFinalMessageIDs ( )
1824+ self . pruneProvisionalFinalMessages ( )
1825+ self . pruneRunMessageScopes ( )
16991826 }
17001827
17011828 private static func isAssistantMessage( _ message: OpenClawChatMessage ) -> Bool {
@@ -1847,7 +1974,7 @@ public final class OpenClawChatViewModel {
18471974 }
18481975
18491976 private func removePendingLocalUserEcho( for runId: String ) {
1850- guard let messageID = self . pendingLocalUserEchoMessageIDsByRunID [ runId] else { return }
1977+ guard let messageID = pendingLocalUserEchoMessageIDsByRunID [ runId] else { return }
18511978 self . messages. removeAll { $0. id == messageID }
18521979 self . pendingLocalUserEchoMessageIDsByRunID [ runId] = nil
18531980 }
@@ -1938,7 +2065,7 @@ public final class OpenClawChatViewModel {
19382065 guard let lastUserIndex = messages. lastIndex ( where: { $0. role. lowercased ( ) == " user " } ) else {
19392066 return nil
19402067 }
1941- guard let refreshKey = self . userRefreshIdentityKey ( for: messages [ lastUserIndex] ) else {
2068+ guard let refreshKey = userRefreshIdentityKey ( for: messages [ lastUserIndex] ) else {
19422069 return LatestUserTurn (
19432070 refreshKey: nil ,
19442071 occurrence: 0 ,
0 commit comments