* fix(chat): keep session state consistent across resume retries * fix(chat): preserve hydration retry after resume ack * fix(chat): publish branched runtime session id --------- Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
1 parent
b28440d659
commit
be0bbcb2c9
3 files changed
+740
-142
No files matched your search
@@ -154,15 +154,47 @@ internal fun mergeTranscriptWithLive(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
// User rows can carry live/cache-only metadata (for example whether the
|
||||
// bubble continues the active turn). Keep that richer copy when REST
|
||||
// returns the same logical row.
|
||||
val currentUsers = currentMessages.filter { it.role == MessageRole.USER }.toMutableList()
|
||||
val mergedRest =
|
||||
restMessages.map { rest ->
|
||||
if (rest.role != MessageRole.USER) return@map rest
|
||||
val matchIndex =
|
||||
currentUsers.indexOfFirst { it.id == rest.id }.takeIf { it >= 0 }
|
||||
?: currentUsers.indexOfFirst { sameLogicalMessage(rest, it) }
|
||||
if (matchIndex >= 0) currentUsers.removeAt(matchIndex) else rest
|
||||
}
|
||||
val restIds = restMessages.map { it.id }.toSet()
|
||||
val liveTail =
|
||||
currentMessages.filter { old ->
|
||||
old.id !in restIds && restMessages.none { sameLogicalMessage(it, old) }
|
||||
}
|
||||
if (liveTail.isEmpty()) return restMessages
|
||||
return (restMessages + liveTail).sortedBy { it.timestamp }
|
||||
if (liveTail.isEmpty()) return mergedRest
|
||||
return (mergedRest + liveTail).sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/** Merge a REST page without matching it against already-settled transcript rows. */
|
||||
internal fun mergeIncrementalTranscriptPage(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
sessionId: String,
|
||||
offset: Int,
|
||||
): List<ChatMessage> {
|
||||
val settledEnd =
|
||||
currentMessages.indexOfLast { message ->
|
||||
serverMessageIndex(message.id, sessionId)?.let { it < offset } == true
|
||||
}
|
||||
return currentMessages.take(settledEnd + 1) +
|
||||
mergeTranscriptWithLive(restMessages, currentMessages.drop(settledEnd + 1))
|
||||
}
|
||||
|
||||
private fun serverMessageIndex(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
@@ -318,6 +350,21 @@ class ChatViewModel(
|
||||
/** Maps an in-flight RPC id to its method for UI error labeling. */
|
||||
private val idToMethod = ConcurrentHashMap<String, String>()
|
||||
|
||||
private data class SessionRequest(
|
||||
val generation: Long,
|
||||
val resumeSequence: Long = 0L,
|
||||
val sessionId: String? = null,
|
||||
)
|
||||
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private var sessionGeneration = 0L
|
||||
private var resumeRequestSequence = 0L
|
||||
private var activeResumeRequestSequence = 0L
|
||||
private var hydrationRequestSequence = 0L
|
||||
private var activeHydrationRequestSequence = 0L
|
||||
private var resumedGeneration = -1L
|
||||
private var hydratedGeneration = -1L
|
||||
|
||||
/** Runtime TUI session returned by session.resume; Desktop storage keeps the original ID. */
|
||||
private var runtimeSessionId: String? = null
|
||||
|
||||
@@ -520,14 +567,7 @@ class ChatViewModel(
|
||||
val currentId = _uiState.value.currentSessionId
|
||||
if (currentId != null) {
|
||||
if (sessionHasServerPresence) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to currentId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(currentId)
|
||||
resumeSession(currentId, sessionGeneration)
|
||||
}
|
||||
// No server-side row yet (session created but never prompted):
|
||||
// resuming would 4007 and the REST transcript would 404. Keep the
|
||||
@@ -545,6 +585,14 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
private fun handleWsEvent(event: WsEvent) {
|
||||
// RpcError is reduced before ViewModel request handling. Drop stale
|
||||
// session errors here so the shared reducer cannot clear loading or
|
||||
// surface an error for a newly selected session.
|
||||
if (event is WsEvent.RpcError && isStaleSessionRequest(event.id)) {
|
||||
forgetRequest(event.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Flush any throttled reasoning before a state transition so the
|
||||
// finalized/orphan message carries the latest reasoning text.
|
||||
when (event) {
|
||||
@@ -764,6 +812,8 @@ class ChatViewModel(
|
||||
result: Any?,
|
||||
) {
|
||||
val method = idToMethod.remove(id) ?: return
|
||||
val request = sessionRequestById.remove(id)
|
||||
if (request != null && isStaleSessionRequest(request)) return
|
||||
when (method) {
|
||||
WsMethods.SESSION_CREATE -> {
|
||||
val resultMap = result as? Map<String, Any?> ?: return
|
||||
@@ -811,25 +861,18 @@ class ChatViewModel(
|
||||
// misses) and the REST transcript 404.
|
||||
val runtimeId = resultMap["session_id"] as? String ?: return
|
||||
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
|
||||
val generation =
|
||||
resetSessionState(
|
||||
sessionId = storageId,
|
||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "Hermes",
|
||||
isLoading = false,
|
||||
)
|
||||
runtimeSessionId = runtimeId
|
||||
ActiveSessionHolder.set(runtimeId)
|
||||
sessionHasServerPresence = false
|
||||
sessionGoneRecoveryInFlight = false
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentSessionId = storageId,
|
||||
isLoading = false,
|
||||
messages = emptyList(),
|
||||
chatTitle = (resultMap["title"] as? String)?.takeIf { t -> t.isNotBlank() } ?: "Hermes",
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
)
|
||||
}
|
||||
ActiveSessionHolder.set(runtimeId)
|
||||
_streamingState.update { StreamingState() }
|
||||
addSystemMessage("Session branched", persist = true)
|
||||
loadSessionMessages(storageId)
|
||||
loadSessionMessages(storageId, generation)
|
||||
loadSessions()
|
||||
fetchContextUsage()
|
||||
}
|
||||
@@ -860,7 +903,8 @@ class ChatViewModel(
|
||||
// Resume succeeded — the gateway confirmed the DB row.
|
||||
sessionHasServerPresence = true
|
||||
val sessionId =
|
||||
(resultMap?.get("resumed") as? String)
|
||||
request?.sessionId
|
||||
?: (resultMap?.get("resumed") as? String)
|
||||
?: _uiState.value.currentSessionId
|
||||
|
||||
// Parse session info from backend — model, provider, reasoning_effort
|
||||
@@ -874,16 +918,10 @@ class ChatViewModel(
|
||||
// the WS round-trip. Calling loadCachedMessages() here would
|
||||
// overwrite any message the user sent between switchSession() and
|
||||
// the server ack, making the chat appear to go blank.
|
||||
// A successful resume also retires any pending retry job and
|
||||
// clears the retry indicators (a reconnect re-resume landing
|
||||
// while a backoff timer was armed must not double-fire).
|
||||
resumeRetryJob?.cancel()
|
||||
resumeRetryJob = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isResumeRetrying = false,
|
||||
resumeError = null,
|
||||
errorMessage = null,
|
||||
currentSessionId = sessionId,
|
||||
currentSessionModel =
|
||||
if (model != null && provider != null) {
|
||||
@@ -903,6 +941,9 @@ class ChatViewModel(
|
||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId)
|
||||
addSystemMessage("Session resumed")
|
||||
fetchContextUsage()
|
||||
val generation = request?.generation ?: sessionGeneration
|
||||
resumedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
}
|
||||
|
||||
WsMethods.SESSION_INTERRUPT -> {
|
||||
@@ -980,6 +1021,8 @@ class ChatViewModel(
|
||||
error: Any?,
|
||||
) {
|
||||
val method = idToMethod.remove(id) ?: return
|
||||
val request = sessionRequestById.remove(id)
|
||||
if (request != null && isStaleSessionRequest(request)) return
|
||||
val errorMsg =
|
||||
when (error) {
|
||||
is Map<*, *> -> error["message"] as? String ?: error.toString()
|
||||
@@ -992,9 +1035,11 @@ class ChatViewModel(
|
||||
// brief backoff usually clears it. Persistent failure ends in the
|
||||
// explicit error + Retry state.
|
||||
if (method == WsMethods.SESSION_RESUME) {
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
handleResumeFailure(sessionId, errorMsg)
|
||||
val generation = request?.generation ?: sessionGeneration
|
||||
if (resumedGeneration == generation) resumedGeneration = -1L
|
||||
handleResumeFailure(sessionId, generation, errorMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1275,11 +1320,12 @@ class ChatViewModel(
|
||||
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||
val params = mutableMapOf<String, Any>("session_id" to sessionId)
|
||||
if (arg.isNotBlank()) params["name"] = arg
|
||||
val generation = sessionGeneration
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_BRANCH,
|
||||
params,
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_BRANCH) },
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_BRANCH, generation) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1429,35 +1475,24 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionCreateCounter = 0L
|
||||
|
||||
fun createNewSession(setLoading: Boolean = true) {
|
||||
// A fresh create has no persisted row until the first prompt.
|
||||
sessionHasServerPresence = false
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = setLoading,
|
||||
messages = emptyList(),
|
||||
chatTitle = "Hermes",
|
||||
)
|
||||
}
|
||||
_streamingState.update { StreamingState() }
|
||||
streamingController.resetStreaming()
|
||||
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_CREATE,
|
||||
params = mapOf("source" to "desktop"),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||
)
|
||||
}
|
||||
// B7 safety timeout: clear loading state if RPC response never arrives
|
||||
if (setLoading && !isTestEnvironment()) {
|
||||
val generation = ++sessionCreateCounter
|
||||
viewModelScope.launch {
|
||||
delay(10_000L)
|
||||
// Only clear if no newer session creation has started — prevents a
|
||||
// stale timeout from wiping the loading flag of a subsequent request.
|
||||
if (generation == sessionCreateCounter && _uiState.value.isLoading) {
|
||||
if (generation == sessionGeneration && _uiState.value.isLoading) {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
@@ -1487,7 +1522,7 @@ class ChatViewModel(
|
||||
// No server-side copy yet (created but never prompted): the REST
|
||||
// transcript 404s and would burn the resume retry budget for nothing.
|
||||
if (!sessionHasServerPresence) return
|
||||
loadSessionMessages(sessionId)
|
||||
loadSessionMessages(sessionId, sessionGeneration)
|
||||
}
|
||||
|
||||
fun refreshSettings() {
|
||||
@@ -1695,10 +1730,6 @@ class ChatViewModel(
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
|
||||
// Reset streaming, pagination, and any pending resume retry before
|
||||
// resuming the Desktop session.
|
||||
cancelResumeRetry()
|
||||
runtimeSessionId = null
|
||||
// The id came from the gateway's own session list / picker — its row
|
||||
// is expected to exist, so resume it optimistically on reconnect even
|
||||
// before the REST page confirms (a transient 500 must not strand the
|
||||
@@ -1707,57 +1738,34 @@ class ChatViewModel(
|
||||
sessionHasServerPresence = true
|
||||
sessionGoneRecoveryInFlight = false
|
||||
pendingGoneSessionNotice = false
|
||||
loadedMessageOffset = 0
|
||||
streamingController.resetStreaming()
|
||||
_uiState.update {
|
||||
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
isLoadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
currentSessionId = sessionId,
|
||||
messages = emptyList(),
|
||||
chatTitle = title,
|
||||
showSessionPicker = false,
|
||||
isAgentTyping = false,
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
resumeError = null,
|
||||
isResumeRetrying = false,
|
||||
)
|
||||
}
|
||||
// Mirror the active session id app-wide (issue #532).
|
||||
ActiveSessionHolder.set(sessionId)
|
||||
_streamingState.update { StreamingState() }
|
||||
val title = _uiState.value.sessions.find { it.id == sessionId }?.title ?: "Hermes"
|
||||
val generation = resetSessionState(sessionId, title, isLoading = true)
|
||||
viewModelScope.launch {
|
||||
// Warm-cache fast-path (desktop parity): paint the cached Room
|
||||
// transcript immediately so the screen never sits blank, then load
|
||||
// the fresh server transcript in parallel. If the cache is empty
|
||||
// the spinner stays up until the server page lands.
|
||||
loadCachedMessages(sessionId)
|
||||
// Resume the selected desktop session, then load its complete transcript.
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId)
|
||||
loadCachedMessages(sessionId, generation)
|
||||
// Resume the selected desktop session and hydrate its transcript.
|
||||
resumeSession(sessionId, generation)
|
||||
loadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCachedMessages(sessionId: String): Job =
|
||||
private fun loadCachedMessages(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
): Job =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId))
|
||||
_uiState.update { state ->
|
||||
// Only paint if still showing this session AND no fresher server
|
||||
// page has landed yet (a fast REST fetch must not be clobbered
|
||||
// by a stale cache read that loses the race).
|
||||
if (state.currentSessionId == sessionId && state.messages.isEmpty()) {
|
||||
if (isCurrentSessionRequest(sessionId, generation) &&
|
||||
state.messages.isEmpty() &&
|
||||
cachedMessages.isNotEmpty()
|
||||
) {
|
||||
state.copy(messages = cachedMessages, isLoading = false)
|
||||
} else {
|
||||
state
|
||||
@@ -1765,11 +1773,18 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSessionMessages(sessionId: String) {
|
||||
private fun loadSessionMessages(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
val requestSequence = ++hydrationRequestSequence
|
||||
activeHydrationRequestSequence = requestSequence
|
||||
viewModelScope.launch {
|
||||
val messageCount = fetchServerMessageCount(sessionId)
|
||||
val messageCount = fetchServerMessageCount(sessionId, generation, requestSequence)
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
val offset = (messageCount - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
val result = fetchMessagePage(sessionId, offset, MESSAGE_PAGE_SIZE)
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
// REST 200 — the gateway has the row for this session.
|
||||
@@ -1780,8 +1795,9 @@ class ChatViewModel(
|
||||
withContext(Dispatchers.IO) {
|
||||
repo.persistMessages(chatMessages, sessionId)
|
||||
}
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@launch
|
||||
_uiState.update { state ->
|
||||
if (state.currentSessionId != sessionId) return@update state
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update state
|
||||
// Merge, don't replace: a reload mid-turn must not
|
||||
// drop live WS bubbles (running tool call, streaming
|
||||
// answer) the server hasn't persisted yet. Issue #771.
|
||||
@@ -1793,11 +1809,14 @@ class ChatViewModel(
|
||||
isLoadingOlder = false,
|
||||
)
|
||||
}
|
||||
hydratedGeneration = generation
|
||||
finishResumeWhenHydrated(generation)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
if (hydratedGeneration == generation) hydratedGeneration = -1L
|
||||
_uiState.update {
|
||||
if (it.currentSessionId != sessionId) return@update it
|
||||
if (!isCurrentHydration(sessionId, generation, requestSequence)) return@update it
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
@@ -1807,7 +1826,11 @@ class ChatViewModel(
|
||||
// retry (desktop parity) instead of a one-shot snackbar —
|
||||
// a transient backend/network blip recovers on its own,
|
||||
// and a persistent failure ends in an explicit Retry.
|
||||
handleResumeFailure(sessionId, "Failed to load messages: ${result.error.message}")
|
||||
handleResumeFailure(
|
||||
sessionId,
|
||||
generation,
|
||||
"Failed to load messages: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1815,6 +1838,86 @@ class ChatViewModel(
|
||||
|
||||
// ── Session resume recovery (desktop parity) ─────────────────────────
|
||||
|
||||
private fun resetSessionState(
|
||||
sessionId: String?,
|
||||
title: String,
|
||||
isLoading: Boolean,
|
||||
): Long {
|
||||
val generation = ++sessionGeneration
|
||||
cancelResumeRetry()
|
||||
resumedGeneration = -1L
|
||||
hydratedGeneration = -1L
|
||||
runtimeSessionId = null
|
||||
ActiveSessionHolder.set(sessionId)
|
||||
loadedMessageOffset = 0
|
||||
isSyncingMessages = false
|
||||
streamingController.resetStreaming()
|
||||
_streamingState.value = StreamingState()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
messages = emptyList(),
|
||||
currentSessionId = sessionId,
|
||||
chatTitle = title,
|
||||
isAgentTyping = false,
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
isLoading = isLoading,
|
||||
isLoadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
streamingMessage = null,
|
||||
errorMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
showSessionPicker = false,
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchMatchIndices = emptyList(),
|
||||
currentSearchMatchIndex = -1,
|
||||
showModelPicker = false,
|
||||
modelPickerLoading = false,
|
||||
currentSessionModel = null,
|
||||
reasoningLevel = null,
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
pendingAttachments = emptyList(),
|
||||
reactionKind = null,
|
||||
subagentIndicators = emptyList(),
|
||||
todos = emptyList(),
|
||||
resumeError = null,
|
||||
isResumeRetrying = false,
|
||||
)
|
||||
}
|
||||
return generation
|
||||
}
|
||||
|
||||
private fun resumeSession(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
) {
|
||||
val requestSequence = ++resumeRequestSequence
|
||||
activeResumeRequestSequence = requestSequence
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id ->
|
||||
trackSessionRequest(
|
||||
id = id,
|
||||
method = WsMethods.SESSION_RESUME,
|
||||
generation = generation,
|
||||
resumeSequence = requestSequence,
|
||||
sessionId = sessionId,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId, generation)
|
||||
}
|
||||
|
||||
private fun cancelResumeRetry() {
|
||||
resumeRetryJob?.cancel()
|
||||
resumeRetryJob = null
|
||||
@@ -1822,6 +1925,24 @@ class ChatViewModel(
|
||||
resumeRetryAttempt = 0
|
||||
}
|
||||
|
||||
private fun finishResumeWhenHydrated(generation: Long) {
|
||||
if (generation != sessionGeneration ||
|
||||
resumedGeneration != generation ||
|
||||
hydratedGeneration != generation
|
||||
) {
|
||||
return
|
||||
}
|
||||
cancelResumeRetry()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isResumeRetrying = false,
|
||||
resumeError = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeRetryDelayMs(attempt: Int): Long =
|
||||
minOf(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * (1L shl attempt))
|
||||
|
||||
@@ -1873,10 +1994,12 @@ class ChatViewModel(
|
||||
|
||||
private fun handleResumeFailure(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
errorMessage: String,
|
||||
) {
|
||||
// Only handle if still on this session.
|
||||
if (_uiState.value.currentSessionId != sessionId) return
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
|
||||
// New session → reset the counter for a fresh backoff cycle.
|
||||
if (resumeRetrySessionId != sessionId) {
|
||||
@@ -1898,6 +2021,7 @@ class ChatViewModel(
|
||||
isLoading = false,
|
||||
isResumeRetrying = false,
|
||||
resumeError = errorMessage,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -1918,6 +2042,7 @@ class ChatViewModel(
|
||||
isLoading = false,
|
||||
isResumeRetrying = true,
|
||||
resumeError = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1927,7 +2052,7 @@ class ChatViewModel(
|
||||
delay(delayMs)
|
||||
// Re-check liveness at fire time: the user may have switched
|
||||
// sessions or the gateway may have reconnected meanwhile.
|
||||
if (_uiState.value.currentSessionId != sessionId) return@launch
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
|
||||
_uiState.update { it.copy(isResumeRetrying = false) }
|
||||
if (_uiState.value.messages.isEmpty()) {
|
||||
@@ -1935,14 +2060,7 @@ class ChatViewModel(
|
||||
}
|
||||
// Retry the full resume: rebind the runtime via WS + refresh
|
||||
// the transcript via REST. Both are idempotent.
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId)
|
||||
resumeSession(sessionId, generation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1953,6 +2071,7 @@ class ChatViewModel(
|
||||
*/
|
||||
fun retryResumeSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val generation = sessionGeneration
|
||||
cancelResumeRetry()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -1961,21 +2080,13 @@ class ChatViewModel(
|
||||
isLoading = true,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
launch(Dispatchers.IO) {
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
|
||||
)
|
||||
}
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
resumeSession(sessionId, generation)
|
||||
}
|
||||
|
||||
fun loadOlderMessages() {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
val generation = sessionGeneration
|
||||
if (!state.hasOlderMessages || state.isLoadingOlder || loadedMessageOffset <= 0) return
|
||||
val oldOffset = loadedMessageOffset
|
||||
val newOffset = (oldOffset - MESSAGE_PAGE_SIZE).coerceAtLeast(0)
|
||||
@@ -1984,12 +2095,13 @@ class ChatViewModel(
|
||||
viewModelScope.launch {
|
||||
when (val result = fetchMessagePage(sessionId, newOffset, limit)) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val returnedOffset = result.data.offset ?: newOffset
|
||||
val older = mapServerMessages(sessionId, result.data.messages.orEmpty(), returnedOffset)
|
||||
loadedMessageOffset = returnedOffset
|
||||
withContext(Dispatchers.IO) { repo.persistMessages(older, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (current.currentSessionId != sessionId) return@update current
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||
current.copy(
|
||||
messages = (older + current.messages).distinctBy { it.id },
|
||||
isLoadingOlder = false,
|
||||
@@ -1999,7 +2111,13 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(isLoadingOlder = false) }
|
||||
_uiState.update {
|
||||
if (isCurrentSessionRequest(sessionId, generation)) {
|
||||
it.copy(isLoadingOlder = false)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2008,6 +2126,7 @@ class ChatViewModel(
|
||||
fun syncCurrentSession() {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return
|
||||
val generation = sessionGeneration
|
||||
if (isSyncingMessages || state.isLoading || state.isLoadingOlder || state.isAgentTyping ||
|
||||
_streamingState.value.streamingMessage != null
|
||||
) {
|
||||
@@ -2024,11 +2143,12 @@ class ChatViewModel(
|
||||
try {
|
||||
when (val result = fetchMessagePage(sessionId, nextOffset, MESSAGE_PAGE_SIZE)) {
|
||||
is NetworkResult.Success -> {
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@launch
|
||||
val incoming = mapServerMessages(sessionId, result.data.messages.orEmpty(), nextOffset)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (current.currentSessionId != sessionId) return@update current
|
||||
if (!isCurrentSessionRequest(sessionId, generation)) return@update current
|
||||
// Issue #771: the sync merge was dropping the
|
||||
// newest tool bubble — the incoming REST page
|
||||
// didn't include it yet (server persists tool rows
|
||||
@@ -2094,7 +2214,7 @@ class ChatViewModel(
|
||||
is NetworkResult.Failure -> {}
|
||||
}
|
||||
} finally {
|
||||
isSyncingMessages = false
|
||||
if (generation == sessionGeneration) isSyncingMessages = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2215,7 +2335,11 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchServerMessageCount(sessionId: String): Int {
|
||||
private suspend fun fetchServerMessageCount(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
requestSequence: Long,
|
||||
): Int {
|
||||
val known =
|
||||
_uiState.value.sessions
|
||||
.find { it.id == sessionId }
|
||||
@@ -2230,23 +2354,30 @@ class ChatViewModel(
|
||||
val count = sessions.find { it.id == sessionId }?.message_count
|
||||
if (count != null) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
sessions =
|
||||
current.sessions.map {
|
||||
if (it.id == sessionId) {
|
||||
it.copy(
|
||||
messageCount = count,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isCurrentHydration(sessionId, generation, requestSequence)) {
|
||||
current.copy(
|
||||
sessions =
|
||||
current.sessions.map {
|
||||
if (it.id == sessionId) {
|
||||
it.copy(messageCount = count)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
return known ?: _uiState.value.messages.size
|
||||
return known
|
||||
?: if (isCurrentHydration(sessionId, generation, requestSequence)) {
|
||||
_uiState.value.messages.size
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMessagePage(
|
||||
@@ -2292,9 +2423,6 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
|
||||
val mapped = mutableListOf<ChatMessage>()
|
||||
// The gateway stores a reasoning-model's thinking as its OWN assistant
|
||||
// row (content = "", reasoning = trace) directly before the answer row.
|
||||
@@ -2340,6 +2468,8 @@ class ChatViewModel(
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
val items = HostMediaExtractor.extract(rawContent)
|
||||
if (items.isNotEmpty()) {
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
attachments =
|
||||
items
|
||||
@@ -2608,11 +2738,6 @@ class ChatViewModel(
|
||||
_uiState.update { it.copy(openError = null) }
|
||||
}
|
||||
|
||||
private fun serverMessageIndex(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
|
||||
|
||||
private fun sameMessages(
|
||||
left: List<ChatMessage>,
|
||||
right: List<ChatMessage>,
|
||||
@@ -3004,6 +3129,41 @@ class ChatViewModel(
|
||||
idToMethod[id] = method
|
||||
}
|
||||
|
||||
private fun trackSessionRequest(
|
||||
id: String,
|
||||
method: String,
|
||||
generation: Long,
|
||||
resumeSequence: Long = 0L,
|
||||
sessionId: String? = null,
|
||||
) {
|
||||
sessionRequestById[id] = SessionRequest(generation, resumeSequence, sessionId)
|
||||
trackRequest(id, method)
|
||||
}
|
||||
|
||||
private fun isCurrentSessionRequest(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
): Boolean = generation == sessionGeneration && sessionId == _uiState.value.currentSessionId
|
||||
|
||||
private fun isCurrentHydration(
|
||||
sessionId: String,
|
||||
generation: Long,
|
||||
requestSequence: Long,
|
||||
): Boolean = requestSequence == activeHydrationRequestSequence && isCurrentSessionRequest(sessionId, generation)
|
||||
|
||||
private fun isStaleSessionRequest(id: String): Boolean =
|
||||
sessionRequestById[id]?.let(::isStaleSessionRequest) == true
|
||||
|
||||
private fun isStaleSessionRequest(request: SessionRequest): Boolean =
|
||||
request.generation != sessionGeneration ||
|
||||
(request.sessionId != null && request.sessionId != _uiState.value.currentSessionId) ||
|
||||
(request.resumeSequence != 0L && request.resumeSequence != activeResumeRequestSequence)
|
||||
|
||||
private fun forgetRequest(id: String) {
|
||||
idToMethod.remove(id)
|
||||
sessionRequestById.remove(id)
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────
|
||||
// Compatibility façade: stable public API around ChatSearchDelegate.
|
||||
// These thin delegates keep ChatViewModel's public surface intact while
|
||||
|
||||
Reference in new issue
Block a user