@@ -89,8 +89,17 @@ class ChatController internal constructor(
8989 private var lastHealthPollAtMs: Long? = null
9090 private var commandsAgentId: String? = null
9191
92+ // Highest transcript seq applied for the active session. Survives disconnects so the
93+ // reconnect catch-up can fetch only missed rows; resets wherever messages reset.
94+ private var lastAppliedTranscriptSeq: Long? = null
95+
96+ // Armed on disconnect, consumed on the next healthy transition; keeps routine health
97+ // polls from re-running catch-up while connected.
98+ private var reconnectCatchUpPending = false
99+
92100 /* * Clears transient chat state when the operator gateway session disconnects. */
93101 fun onDisconnected (message : String ) {
102+ reconnectCatchUpPending = true
94103 _healthOk .value = false
95104 _errorText .value = null
96105 _commands .value = emptyList()
@@ -240,6 +249,7 @@ class ChatController internal constructor(
240249 _historyLoading .value = true
241250 if (clearMessages) {
242251 _messages .value = emptyList()
252+ lastAppliedTranscriptSeq = null
243253 }
244254 return generation
245255 }
@@ -419,7 +429,7 @@ class ChatController internal constructor(
419429 scope.launch { pollHealthIfNeeded(force = false ) }
420430 }
421431 " health" -> {
422- _healthOk .value = true
432+ markGatewayHealthy()
423433 refreshCommandsAfterReconnect()
424434 }
425435 " seqGap" -> {
@@ -455,22 +465,8 @@ class ChatController internal constructor(
455465 refreshSessions : Boolean ,
456466 ) {
457467 try {
458- val historyJson =
459- requestGateway(
460- " chat.history" ,
461- buildJsonObject { put(" sessionKey" , JsonPrimitive (sessionKey)) }.toString(),
462- )
463- if (! isCurrentHistoryLoad(sessionKey, _sessionKey .value, generation, historyLoadGeneration.get())) return
464- val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages .value)
465- updateSessionFromHistory(history)
466- prunePersistedOptimisticMessages(history.messages)
467- _messages .value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
468- _sessionId .value = history.sessionId
468+ if (fetchAndApplyCurrentHistory(sessionKey, generation, updateSessionInfo = true ) == null ) return
469469 _historyLoading .value = false
470- history.thinkingLevel
471- ?.trim()
472- ?.takeIf { it.isNotEmpty() }
473- ?.let { _thinkingLevel .value = it }
474470
475471 pollHealthIfNeeded(force = forceHealth)
476472 if (refreshSessions) {
@@ -483,6 +479,47 @@ class ChatController internal constructor(
483479 }
484480 }
485481
482+ /* *
483+ * Fetches the full recent history page and applies it as the new message baseline.
484+ * Returns null when a newer load superseded this one before the response arrived.
485+ */
486+ private suspend fun fetchAndApplyCurrentHistory (
487+ sessionKey : String ,
488+ generation : Long ,
489+ updateSessionInfo : Boolean ,
490+ ): ChatHistory ? {
491+ val historyJson =
492+ requestGateway(
493+ " chat.history" ,
494+ buildJsonObject { put(" sessionKey" , JsonPrimitive (sessionKey)) }.toString(),
495+ )
496+ if (! isCurrentHistoryLoad(sessionKey, _sessionKey .value, generation, historyLoadGeneration.get())) return null
497+ val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages .value)
498+ applyWholesaleHistory(history, updateSessionInfo = updateSessionInfo)
499+ return history
500+ }
501+
502+ /* * Replaces the visible transcript with a full history page and rebases the seq cursor. */
503+ private fun applyWholesaleHistory (
504+ history : ChatHistory ,
505+ updateSessionInfo : Boolean ,
506+ ) {
507+ if (updateSessionInfo) {
508+ updateSessionFromHistory(history)
509+ }
510+ prunePersistedOptimisticMessages(history.messages)
511+ _messages .value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
512+ _sessionId .value = history.sessionId
513+ history.thinkingLevel
514+ ?.trim()
515+ ?.takeIf { it.isNotEmpty() }
516+ ?.let { _thinkingLevel .value = it }
517+ // A full page is the authoritative baseline; a pending reconnect catch-up would only
518+ // re-fetch rows this page already delivered. Null seq (legacy gateway) disables catch-up.
519+ lastAppliedTranscriptSeq = history.maxTranscriptSeq
520+ reconnectCatchUpPending = false
521+ }
522+
486523 private suspend fun fetchSessions (limit : Int? ) {
487524 try {
488525 val params =
@@ -531,7 +568,7 @@ class ChatController internal constructor(
531568 lastHealthPollAtMs = now
532569 try {
533570 requestGateway(" health" , null )
534- _healthOk .value = true
571+ markGatewayHealthy()
535572 if (_commands .value.isEmpty() || commandsAgentId != resolveAgentIdForSessionKey(_sessionKey .value)) {
536573 fetchCommands()
537574 }
@@ -540,6 +577,83 @@ class ChatController internal constructor(
540577 }
541578 }
542579
580+ private fun markGatewayHealthy () {
581+ _healthOk .value = true
582+ maybeCatchUpAfterReconnect()
583+ }
584+
585+ private fun maybeCatchUpAfterReconnect () {
586+ if (! reconnectCatchUpPending) return
587+ reconnectCatchUpPending = false
588+ // Fresh sessions and legacy gateways have no baseline; the existing full-fetch
589+ // paths (load/refresh/terminal events) stay the only history source for them.
590+ val afterSeq = lastAppliedTranscriptSeq ? : return
591+ scope.launch { catchUpHistoryAfterReconnect(afterSeq) }
592+ }
593+
594+ /* * Fetches only transcript rows missed while disconnected by paging the afterSeq cursor. */
595+ private suspend fun catchUpHistoryAfterReconnect (startAfterSeq : Long ) {
596+ val sessionKey = _sessionKey .value
597+ val generation = historyLoadGeneration.get()
598+ var afterSeq = startAfterSeq
599+ while (true ) {
600+ val historyJson =
601+ try {
602+ requestGateway(
603+ " chat.history" ,
604+ buildJsonObject {
605+ put(" sessionKey" , JsonPrimitive (sessionKey))
606+ put(" afterSeq" , JsonPrimitive (afterSeq))
607+ }.toString(),
608+ )
609+ } catch (_: Throwable ) {
610+ // Version-skew fallback: older gateways reject the unknown afterSeq param,
611+ // 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+ }
617+ return
618+ }
619+ if (! isCurrentHistoryLoad(sessionKey, _sessionKey .value, generation, historyLoadGeneration.get())) return
620+ val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages .value)
621+ val cursor = history.cursor
622+ if (cursor == null ) {
623+ // Version-skew fallback: no afterSeq echo means the gateway ignored the cursor
624+ // and returned a legacy full-history page; apply it wholesale.
625+ applyWholesaleHistory(history, updateSessionInfo = false )
626+ return
627+ }
628+ applyHistoryDeltaPage(history, cursor)
629+ 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
632+ afterSeq = nextAfterSeq
633+ }
634+ }
635+
636+ /* * Appends one catch-up page, deduping rows the client already shows by message identity. */
637+ private fun applyHistoryDeltaPage (
638+ history : ChatHistory ,
639+ cursor : ChatHistoryCursor ,
640+ ) {
641+ val delta = history.messages
642+ if (delta.isNotEmpty()) {
643+ val deltaKeys = delta.mapNotNull(::messageIdentityKey).toSet()
644+ val retained = _messages .value.filterNot { messageIdentityKey(it) in deltaKeys }
645+ val merged = retained + delta
646+ prunePersistedOptimisticMessages(merged)
647+ _messages .value = mergeOptimisticMessages(incoming = merged, optimistic = optimisticMessagesByRunId.values)
648+ }
649+ // The cursor advances over raw transcript rows, so pages whose rows were all
650+ // projection-filtered still move the baseline forward.
651+ val advancedTo = cursor.nextAfterSeq ? : history.maxTranscriptSeq ? : cursor.afterSeq
652+ if (advancedTo > (lastAppliedTranscriptSeq ? : 0L )) {
653+ lastAppliedTranscriptSeq = advancedTo
654+ }
655+ }
656+
543657 private fun refreshCommandsAfterReconnect () {
544658 if (_commands .value.isNotEmpty() && commandsAgentId == resolveAgentIdForSessionKey(_sessionKey .value)) return
545659 scope.launch { fetchCommands() }
@@ -578,37 +692,11 @@ class ChatController internal constructor(
578692 _streamingAssistantText .value = null
579693 scope.launch {
580694 try {
581- val currentSessionKey = _sessionKey .value
582- val currentGeneration = historyLoadGeneration.get()
583- val historyJson =
584- requestGateway(
585- " chat.history" ,
586- buildJsonObject { put(" sessionKey" , JsonPrimitive (currentSessionKey)) }.toString(),
587- )
588- if (
589- ! isCurrentHistoryLoad(
590- currentSessionKey,
591- _sessionKey .value,
592- currentGeneration,
593- historyLoadGeneration.get(),
594- )
595- ) {
596- return @launch
597- }
598- val history =
599- parseHistory(
600- historyJson,
601- sessionKey = currentSessionKey,
602- previousMessages = _messages .value,
603- )
604- updateSessionFromHistory(history)
605- prunePersistedOptimisticMessages(history.messages)
606- _messages .value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
607- _sessionId .value = history.sessionId
608- history.thinkingLevel
609- ?.trim()
610- ?.takeIf { it.isNotEmpty() }
611- ?.let { _thinkingLevel .value = it }
695+ fetchAndApplyCurrentHistory(
696+ sessionKey = _sessionKey .value,
697+ generation = historyLoadGeneration.get(),
698+ updateSessionInfo = true ,
699+ )
612700 } catch (_: Throwable ) {
613701 // best-effort
614702 }
@@ -764,36 +852,11 @@ class ChatController internal constructor(
764852 private fun refreshCurrentHistoryBestEffort () {
765853 scope.launch {
766854 try {
767- val currentSessionKey = _sessionKey .value
768- val currentGeneration = historyLoadGeneration.get()
769- val historyJson =
770- requestGateway(
771- " chat.history" ,
772- buildJsonObject { put(" sessionKey" , JsonPrimitive (currentSessionKey)) }.toString(),
773- )
774- if (
775- ! isCurrentHistoryLoad(
776- currentSessionKey,
777- _sessionKey .value,
778- currentGeneration,
779- historyLoadGeneration.get(),
780- )
781- ) {
782- return @launch
783- }
784- val history =
785- parseHistory(
786- historyJson,
787- sessionKey = currentSessionKey,
788- previousMessages = _messages .value,
789- )
790- prunePersistedOptimisticMessages(history.messages)
791- _messages .value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
792- _sessionId .value = history.sessionId
793- history.thinkingLevel
794- ?.trim()
795- ?.takeIf { it.isNotEmpty() }
796- ?.let { _thinkingLevel .value = it }
855+ fetchAndApplyCurrentHistory(
856+ sessionKey = _sessionKey .value,
857+ generation = historyLoadGeneration.get(),
858+ updateSessionInfo = false ,
859+ )
797860 } catch (_: Throwable ) {
798861 // best-effort
799862 }
@@ -826,12 +889,30 @@ class ChatController internal constructor(
826889 )
827890 }
828891
892+ // Max over ALL rows (including role-less rows dropped above): the cursor consumes raw
893+ // transcript rows, so the baseline must cover rows this page delivered but did not render.
894+ val maxTranscriptSeq =
895+ array
896+ .mapNotNull { item -> item.asObjectOrNull()?.get(" __openclaw" ).asObjectOrNull()?.get(" seq" ).asLongOrNull() }
897+ .filter { it > 0L }
898+ .maxOrNull()
899+ val cursor =
900+ root[" afterSeq" ].asLongOrNull()?.let { echoedAfterSeq ->
901+ ChatHistoryCursor (
902+ afterSeq = echoedAfterSeq,
903+ nextAfterSeq = root[" nextAfterSeq" ].asLongOrNull(),
904+ hasMore = root[" hasMore" ].asBooleanOrNull() ? : false ,
905+ )
906+ }
907+
829908 return ChatHistory (
830909 sessionKey = sessionKey,
831910 sessionId = sid,
832911 thinkingLevel = thinkingLevel,
833912 messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
834913 sessionInfo = sessionInfo,
914+ maxTranscriptSeq = maxTranscriptSeq,
915+ cursor = cursor,
835916 )
836917 }
837918
0 commit comments