fix(ws): gateway liveness, replay correctness, and streaming flush (#1085)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 00:19:13 +03:00
1 parent aed13d1084
commit 1cdc0dbfc0
8 files changed
+372 -118

No files matched your search

@@ -2,8 +2,11 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Holds chat message-streaming state and logic, extracted from [ChatViewModel]
@@ -27,14 +30,23 @@ class ChatStreamingController(
private val isCurrentSession: (String?) -> Boolean,
private val isTestEnvironment: () -> Boolean,
) {
private companion object {
const val FLUSH_INTERVAL_MS = 33L
}
private val streamingBuffer = java.lang.StringBuilder()
private var lastFlushMs = 0L
private var trailingMessageFlushJob: Job? = null
private val thinkingBuffer = java.lang.StringBuilder()
private var lastThinkingFlushMs = 0L
private var trailingThinkingFlushJob: Job? = null
private val reasoningBuffer = java.lang.StringBuilder()
private var lastReasoningFlushMs = 0L
private var trailingReasoningFlushJob: Job? = null
private fun nowMs(): Long = System.nanoTime() / 1_000_000L
/**
* Resets all streaming buffers. Centralizes the clear logic that was
@@ -42,6 +54,9 @@ class ChatStreamingController(
* ToolStart, ClarifyRequest, session switches, and interrupt handling.
*/
fun resetStreaming() {
trailingMessageFlushJob?.cancel()
trailingThinkingFlushJob?.cancel()
trailingReasoningFlushJob?.cancel()
flushReasoning()
streamingBuffer.clear()
thinkingBuffer.clear()
@@ -73,6 +88,9 @@ class ChatStreamingController(
* buffer-only clear is safe and prevents stale deltas from re-flushing.
*/
fun clearStreamingBuffers() {
trailingMessageFlushJob?.cancel()
trailingThinkingFlushJob?.cancel()
trailingReasoningFlushJob?.cancel()
streamingBuffer.clear()
thinkingBuffer.clear()
reasoningBuffer.clear()
@@ -94,6 +112,7 @@ class ChatStreamingController(
* synchronously so the finalized message carries the latest reasoning.
*/
fun flushPendingReasoning() {
trailingReasoningFlushJob?.cancel()
flushReasoning()
}
@@ -111,8 +130,10 @@ class ChatStreamingController(
* tokens synchronously first makes the sealed orphan complete.
*/
fun flushPendingTokens() {
trailingMessageFlushJob?.cancel()
val currentContent = streamingBuffer.toString()
if (currentContent.isEmpty()) return
lastFlushMs = nowMs()
streamingState.update { state ->
val current = state.streamingMessage ?: return@update state
state.copy(
@@ -130,63 +151,97 @@ class ChatStreamingController(
if (!isCurrentSession(event.sessionId)) return
streamingBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush = (now - lastFlushMs >= 33L) || lastFlushMs == 0L || isTestEnvironment()
val shouldFlush = (now - lastFlushMs >= FLUSH_INTERVAL_MS) || lastFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = streamingBuffer.toString()
lastFlushMs = now
streamingState.update { state ->
val current = state.streamingMessage
if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy(
streamingMessage =
current.copy(
content = currentContent,
reasoningText = currentReasoning,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true,
)
state.copy(
streamingMessage = msg,
isThinking = false,
)
}
}
uiState.update { it.copy(isAgentTyping = true) }
trailingMessageFlushJob?.cancel()
flushTokens(now)
} else {
scheduleTrailingMessageFlush()
}
}
private fun scheduleTrailingMessageFlush() {
if (trailingMessageFlushJob?.isActive == true) return
trailingMessageFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushTokens(nowMs())
}
}
private fun flushTokens(now: Long) {
val currentContent = streamingBuffer.toString()
if (currentContent.isEmpty()) return
lastFlushMs = now
streamingState.update { state ->
val current = state.streamingMessage
if (current != null) {
val currentReasoning =
if (current.reasoningText.isNotBlank()) current.reasoningText else state.reasoningText
state.copy(
streamingMessage =
current.copy(
content = currentContent,
reasoningText = currentReasoning,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = currentContent,
reasoningText = state.reasoningText,
isStreaming = true,
)
state.copy(
streamingMessage = msg,
isThinking = false,
)
}
}
uiState.update { it.copy(isAgentTyping = true) }
}
fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return
thinkingBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush =
(now - lastThinkingFlushMs >= 33L) || lastThinkingFlushMs == 0L || isTestEnvironment()
(now - lastThinkingFlushMs >= FLUSH_INTERVAL_MS) || lastThinkingFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now
streamingState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
trailingThinkingFlushJob?.cancel()
flushThinking(now)
} else {
scheduleTrailingThinkingFlush()
}
}
private fun scheduleTrailingThinkingFlush() {
if (trailingThinkingFlushJob?.isActive == true) return
trailingThinkingFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushThinking(nowMs())
}
}
private fun flushThinking(now: Long) {
val currentContent = thinkingBuffer.toString()
if (currentContent.isEmpty()) return
lastThinkingFlushMs = now
streamingState.update { state ->
state.copy(
isThinking = true,
thinkingText = currentContent,
)
}
}
@@ -194,17 +249,29 @@ class ChatStreamingController(
if (!isCurrentSession(event.sessionId)) return
reasoningBuffer.append(event.token)
val now = System.currentTimeMillis()
val now = nowMs()
// Always flush in tests, or if enough time has passed
val shouldFlush =
(now - lastReasoningFlushMs >= 33L) || lastReasoningFlushMs == 0L || isTestEnvironment()
(now - lastReasoningFlushMs >= FLUSH_INTERVAL_MS) || lastReasoningFlushMs == 0L || isTestEnvironment()
if (shouldFlush) {
trailingReasoningFlushJob?.cancel()
flushReasoning(now)
} else {
scheduleTrailingReasoningFlush()
}
}
private fun flushReasoning(now: Long = System.currentTimeMillis()) {
private fun scheduleTrailingReasoningFlush() {
if (trailingReasoningFlushJob?.isActive == true) return
trailingReasoningFlushJob =
scope.launch {
delay(FLUSH_INTERVAL_MS)
flushReasoning(nowMs())
}
}
private fun flushReasoning(now: Long = nowMs()) {
val currentContent = reasoningBuffer.toString()
if (currentContent.isEmpty()) return
lastReasoningFlushMs = now
@@ -12,7 +12,6 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.ApiClient
@@ -45,10 +44,6 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@@ -838,6 +833,14 @@ class ChatViewModel(
loadSessions()
}
is WsEvent.TranscriptResyncRequired -> {
val current = runtimeSessionId ?: _uiState.value.currentSessionId
if (current != null && (current == event.sessionId || event.sessionId.isEmpty())) {
val storageId = ActiveSessionHolder.resolveStoredSessionId(current) ?: current
loadSessionMessages(storageId, sessionGeneration)
}
}
is WsEvent.ClarifyRequest -> {
_uiState.update {
it.copy(
@@ -74,6 +74,7 @@ object ChatWsEventReducer {
is WsEvent.ReviewSummary -> event.sessionId
is WsEvent.BtwComplete -> event.sessionId
is WsEvent.SessionUsage -> event.sessionId
is WsEvent.TranscriptResyncRequired -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
@@ -174,6 +175,9 @@ object ChatWsEventReducer {
// Change events (issue #784) are consumed by their screens' ViewModels
is WsEvent.ChangeEvent -> ReducerResult(state = state, streamingState = streamingState)
// TranscriptResyncRequired is handled by ChatViewModel directly (refetch history on replay gap)
is WsEvent.TranscriptResyncRequired -> ReducerResult(state = state, streamingState = streamingState)
}
// ── GatewayReady ──────────────────────────────────────────────────