fix(chat): keep interrupted replies and notices in place (#1413)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 16:16:23 +03:00
1 parent c5bd457da3
commit 9f033ac82c
6 files changed
+188 -3

No files matched your search

@@ -1117,6 +1117,9 @@ class ChatViewModel(
}
is WsEvent.MessageToken -> {
// The interrupted partial is already sealed. Wait for its authoritative
// completion instead of letting trailing deltas create a second stream.
if (_streamingState.value.interruptedMessage != null && isCurrentSession(event.sessionId)) return
if (isCurrentSession(event.sessionId)) mainTurnBusy = true
streamingController.handleMessageToken(event)
}
@@ -2019,9 +2022,23 @@ class ChatViewModel(
if (targetsCurrentTurn && status in setOf("interrupted", "not_interrupted")) {
// Never let a late result for an older same-session turn
// erase a newer stream or make its replacement race it.
streamingController.flushPendingTransition()
val interruptedStream = _streamingState.value
sealStreamingMessageIfAny()
val interruptedMessage =
interruptedStream.streamingMessage?.let { stream ->
_uiState.value.messages.firstOrNull { it.id == stream.id }
}
_uiState.update { it.copy(isAgentTyping = false) }
_streamingState.update { StreamingState() }
_streamingState.update {
interruptedStream.copy(
streamingMessage = null,
interruptedMessage = interruptedMessage,
isThinking = false,
thinkingText = "",
isReasoning = false,
)
}
streamingController.resetStreaming()
if (status == "interrupted") addSystemMessage("Session interrupted")
mainTurnBusy = false
@@ -457,7 +457,7 @@ object ChatWsEventReducer {
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
val streaming = streamingState.streamingMessage
val streaming = streamingState.interruptedMessage ?: streamingState.streamingMessage
// Prefer the authoritative reasoning carried in the message.complete
// payload (the gateway's assembled full trace), then fall back to
// whatever reasoning.delta deltas accumulated during streaming.
@@ -663,7 +663,7 @@ object ChatWsEventReducer {
streamingState.streamingMessage
?: return ReducerResult(
state = state.copy(isAgentTyping = false),
streamingState = StreamingState(),
streamingState = streamingState.takeIf { it.interruptedMessage != null } ?: StreamingState(),
)
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
@@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.UsageSnapshotResponse
data class StreamingState(
val streamingMessage: ChatMessage? = null,
/** Sealed by an interrupt acknowledgement; late completion must update this row, not append another. */
val interruptedMessage: ChatMessage? = null,
val isThinking: Boolean = false,
val thinkingText: String = "",
val isReasoning: Boolean = false,