Skip to content

Commit 8a671d9

Browse files
committed
feat(android): resume chat history from the afterSeq catch-up cursor after reconnect
ChatController now tracks the highest applied __openclaw.seq for the active session and, on the first healthy transition after a disconnect, pages chat.history with afterSeq/nextAfterSeq/hasMore to append only the rows missed while offline. Catch-up pages dedupe by the existing message identity keys and reuse the generation-tracked reconciliation; the seq baseline resets wherever messages reset (session switch, wholesale history replace). Gateways that reject or ignore afterSeq fall back to the existing full-history fetch. The duplicated full-history refetch blocks collapse into one fetch-and-apply helper. Part of #100197
1 parent 05c305a commit 8a671d9

3 files changed

Lines changed: 472 additions & 78 deletions

File tree

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt

Lines changed: 159 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -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

apps/android/app/src/main/java/ai/openclaw/app/chat/ChatModels.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,28 @@ data class ChatCommandEntry(
6060

6161
/**
6262
* Snapshot of one chat session, including optional thinking level selected on the gateway.
63+
*
64+
* [maxTranscriptSeq] is the highest `__openclaw.seq` across returned rows; it seeds the
65+
* afterSeq reconnect cursor. [cursor] is non-null only when the gateway echoed `afterSeq`,
66+
* which is the version-skew discriminator between catch-up pages and legacy full pages.
6367
*/
6468
data class ChatHistory(
6569
val sessionKey: String,
6670
val sessionId: String?,
6771
val thinkingLevel: String?,
6872
val messages: List<ChatMessage>,
6973
val sessionInfo: ChatSessionEntry? = null,
74+
val maxTranscriptSeq: Long? = null,
75+
val cursor: ChatHistoryCursor? = null,
76+
)
77+
78+
/**
79+
* afterSeq catch-up cursor echoed by gateways that honored the request param.
80+
*/
81+
data class ChatHistoryCursor(
82+
val afterSeq: Long,
83+
val nextAfterSeq: Long?,
84+
val hasMore: Boolean,
7085
)
7186

7287
/**

0 commit comments

Comments
 (0)