11package ai.openclaw.app.chat
22
3- import ai.openclaw.app.resolveAgentIdFromMainSessionKey
43import ai.openclaw.app.gateway.GatewaySession
54import ai.openclaw.app.gateway.parseChatSendAck
5+ import ai.openclaw.app.resolveAgentIdFromMainSessionKey
66import kotlinx.coroutines.CoroutineScope
77import kotlinx.coroutines.Job
88import kotlinx.coroutines.delay
@@ -93,6 +93,10 @@ class ChatController internal constructor(
9393 // reconnect catch-up can fetch only missed rows; resets wherever messages reset.
9494 private var lastAppliedTranscriptSeq: Long? = null
9595
96+ // Visible session state clears offline; this identity stays paired with the cursor so
97+ // a replacement transcript cannot append onto the prior session after reconnect.
98+ private var lastAppliedTranscriptSessionId: String? = null
99+
96100 // Armed on disconnect, consumed on the next healthy transition; keeps routine health
97101 // polls from re-running catch-up while connected.
98102 private var reconnectCatchUpPending = false
@@ -250,6 +254,7 @@ class ChatController internal constructor(
250254 if (clearMessages) {
251255 _messages .value = emptyList()
252256 lastAppliedTranscriptSeq = null
257+ lastAppliedTranscriptSessionId = null
253258 }
254259 return generation
255260 }
@@ -261,8 +266,7 @@ class ChatController internal constructor(
261266 return key
262267 }
263268
264- private fun resolveAgentIdForSessionKey (parentKey : String ): String =
265- resolveAgentIdFromMainSessionKey(parentKey) ? : " main"
269+ private fun resolveAgentIdForSessionKey (parentKey : String ): String = resolveAgentIdFromMainSessionKey(parentKey) ? : " main"
266270
267271 /* * Queues a chat send without waiting for gateway acceptance. */
268272 fun sendMessage (
@@ -491,7 +495,10 @@ class ChatController internal constructor(
491495 val historyJson =
492496 requestGateway(
493497 " chat.history" ,
494- buildJsonObject { put(" sessionKey" , JsonPrimitive (sessionKey)) }.toString(),
498+ buildJsonObject {
499+ put(" sessionKey" , JsonPrimitive (sessionKey))
500+ put(" offset" , JsonPrimitive (0 ))
501+ }.toString(),
495502 )
496503 if (! isCurrentHistoryLoad(sessionKey, _sessionKey .value, generation, historyLoadGeneration.get())) return null
497504 val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages .value)
@@ -517,6 +524,7 @@ class ChatController internal constructor(
517524 // A full page is the authoritative baseline; a pending reconnect catch-up would only
518525 // re-fetch rows this page already delivered. Null seq (legacy gateway) disables catch-up.
519526 lastAppliedTranscriptSeq = history.maxTranscriptSeq
527+ lastAppliedTranscriptSessionId = history.sessionId
520528 reconnectCatchUpPending = false
521529 }
522530
@@ -588,11 +596,15 @@ class ChatController internal constructor(
588596 // Fresh sessions and legacy gateways have no baseline; the existing full-fetch
589597 // paths (load/refresh/terminal events) stay the only history source for them.
590598 val afterSeq = lastAppliedTranscriptSeq ? : return
591- scope.launch { catchUpHistoryAfterReconnect(afterSeq) }
599+ val sessionId = lastAppliedTranscriptSessionId ? : return
600+ scope.launch { catchUpHistoryAfterReconnect(afterSeq, sessionId) }
592601 }
593602
594603 /* * Fetches only transcript rows missed while disconnected by paging the afterSeq cursor. */
595- private suspend fun catchUpHistoryAfterReconnect (startAfterSeq : Long ) {
604+ private suspend fun catchUpHistoryAfterReconnect (
605+ startAfterSeq : Long ,
606+ expectedSessionId : String ,
607+ ) {
596608 val sessionKey = _sessionKey .value
597609 val generation = historyLoadGeneration.get()
598610 var afterSeq = startAfterSeq
@@ -609,11 +621,7 @@ class ChatController internal constructor(
609621 } catch (_: Throwable ) {
610622 // Version-skew fallback: older gateways reject the unknown afterSeq param,
611623 // so fall back to the plain full-history refetch.
612- try {
613- fetchAndApplyCurrentHistory(sessionKey, generation, updateSessionInfo = false )
614- } catch (_: Throwable ) {
615- // best-effort; a re-disconnect re-arms the catch-up
616- }
624+ refetchCurrentHistoryBestEffort(sessionKey, generation)
617625 return
618626 }
619627 if (! isCurrentHistoryLoad(sessionKey, _sessionKey .value, generation, historyLoadGeneration.get())) return
@@ -625,14 +633,34 @@ class ChatController internal constructor(
625633 applyWholesaleHistory(history, updateSessionInfo = false )
626634 return
627635 }
628- applyHistoryDeltaPage(history, cursor)
629636 val nextAfterSeq = cursor.nextAfterSeq
630- // Advance-only guard: a non-advancing cursor echo must not loop forever.
631- if (! cursor.hasMore || nextAfterSeq == null || nextAfterSeq <= afterSeq) return
637+ if (
638+ cursor.afterSeq != afterSeq ||
639+ history.sessionId != expectedSessionId ||
640+ nextAfterSeq == null ||
641+ nextAfterSeq < afterSeq ||
642+ (cursor.hasMore && nextAfterSeq == afterSeq)
643+ ) {
644+ refetchCurrentHistoryBestEffort(sessionKey, generation)
645+ return
646+ }
647+ applyHistoryDeltaPage(history, cursor)
648+ if (! cursor.hasMore) return
632649 afterSeq = nextAfterSeq
633650 }
634651 }
635652
653+ private suspend fun refetchCurrentHistoryBestEffort (
654+ sessionKey : String ,
655+ generation : Long ,
656+ ) {
657+ try {
658+ fetchAndApplyCurrentHistory(sessionKey, generation, updateSessionInfo = false )
659+ } catch (_: Throwable ) {
660+ // Best-effort; a re-disconnect re-arms catch-up.
661+ }
662+ }
663+
636664 /* * Appends one catch-up page, deduping rows the client already shows by message identity. */
637665 private fun applyHistoryDeltaPage (
638666 history : ChatHistory ,
@@ -891,11 +919,13 @@ class ChatController internal constructor(
891919
892920 // Max over ALL rows (including role-less rows dropped above): the cursor consumes raw
893921 // transcript rows, so the baseline must cover rows this page delivered but did not render.
894- val maxTranscriptSeq =
922+ val transcriptSeqs =
895923 array
896- .mapNotNull { item -> item.asObjectOrNull()?.get(" __openclaw" ).asObjectOrNull()?.get(" seq" ).asLongOrNull() }
897- .filter { it > 0L }
898- .maxOrNull()
924+ .mapNotNull { item ->
925+ val metadata = item.asObjectOrNull()?.get(" __openclaw" ).asObjectOrNull()
926+ metadata?.get(" seq" ).asLongOrNull()
927+ }
928+ val maxTranscriptSeq = transcriptSeqs.filter { it > 0L }.maxOrNull()
899929 val cursor =
900930 root[" afterSeq" ].asLongOrNull()?.let { echoedAfterSeq ->
901931 ChatHistoryCursor (
@@ -1056,9 +1086,14 @@ internal fun parseChatMessageContents(obj: JsonObject): List<ChatMessageContent>
10561086 return emptyList()
10571087}
10581088
1059- private fun parseCreatedSessionKey (json : Json , sessionJson : String ): String? {
1089+ private fun parseCreatedSessionKey (
1090+ json : Json ,
1091+ sessionJson : String ,
1092+ ): String? {
10601093 val root = runCatching { json.parseToJsonElement(sessionJson).asObjectOrNull() }.getOrNull() ? : return null
1094+
10611095 fun clean (value : String? ): String? = value?.trim()?.takeIf { it.isNotEmpty() }
1096+
10621097 return clean(root[" key" ].asStringOrNull())
10631098 ? : clean(root[" sessionKey" ].asStringOrNull())
10641099 ? : root[" session" ].asObjectOrNull()?.let { session ->
0 commit comments