fix(ws): gateway liveness, replay correctness, and streaming flush (#1085)
This commit is contained in:
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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user