fix(chat): clear stale reasoning when a new message starts (#755) (#760)

A new assistant message was seeded with the previous message's
reasoningText (and isReasoning flag) via onMessageStart, and
resetStreaming() never cleared the shared streaming state. If the
next reply produced no reasoning, the stale trace stayed glued to
the new bubble and got persisted to Room.

- onMessageStart: new message + StreamingState start with empty
  reasoning instead of inheriting the previous turn's
- resetStreaming: clears isReasoning + reasoningText on the shared
  state (finalized messages keep their own copy for the card)
- regression tests: MessageStart seeding, no-reasoning persistence,
  reset clearing, finalized-message preservation

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-01 20:14:35 +03:00
1 parent e014d2a0e7
commit 3a96edd4c1
4 files changed
+145 -3

No files matched your search

@@ -62,6 +62,55 @@ class ChatStreamingControllerTest {
assertEquals("Fresh reasoning", streamingState.value.reasoningText)
}
@Test
fun resetStreaming_clearsStreamingReasoningState() =
runTest {
// Issue #755: resetStreaming must ALSO clear the state-level
// reasoning (text + flag) so the next message can never inherit it.
val uiState = MutableStateFlow(ChatUiState())
val streamingState =
MutableStateFlow(
StreamingState(
isReasoning = true,
reasoningText = "Stale reasoning",
),
)
val controller = controller(this, uiState, streamingState, isTestEnvironment = { false })
controller.resetStreaming()
assertEquals("", streamingState.value.reasoningText)
assertEquals(false, streamingState.value.isReasoning)
}
@Test
fun resetStreaming_doesNotClearReasoningOnTheFinalizedMessage() =
runTest {
// The finalized message keeps its own reasoning copy (persisted to
// Room) — only the shared streaming state is reset. This preserves
// "reasoning card persists after streaming".
val uiState = MutableStateFlow(ChatUiState())
val streamingState =
MutableStateFlow(
StreamingState(
streamingMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "final answer",
reasoningText = "real reasoning trace",
isStreaming = false,
),
isReasoning = true,
reasoningText = "real reasoning trace",
),
)
val controller = controller(this, uiState, streamingState, isTestEnvironment = { false })
controller.resetStreaming()
assertEquals("real reasoning trace", streamingState.value.streamingMessage?.reasoningText)
}
private fun controller(
scope: CoroutineScope,
uiState: MutableStateFlow<ChatUiState>,
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -293,6 +294,89 @@ class ChatWsEventReducerTest {
assertTrue(indicator.isRunning)
}
@Test
fun testMessageStart_doesNotSeedReasoningFromPreviousMessage() {
val previousReasoning = "old reasoning trace"
val state =
ChatUiState(
currentSessionId = "session-1",
)
val staleStreaming =
StreamingState(
streamingMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "prev",
reasoningText = previousReasoning,
isStreaming = true,
),
isReasoning = true,
reasoningText = previousReasoning,
)
val startEvent = WsEvent.MessageStart(sessionId = "session-1")
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = staleStreaming,
event = startEvent,
currentSessionId = "session-1",
)
// Issue #755: the new message must NOT inherit the previous message's
// reasoning — the bubble starts blank until real reasoning deltas arrive.
assertEquals("", result.streamingState.streamingMessage?.reasoningText)
assertFalse(result.streamingState.isReasoning)
assertEquals("", result.streamingState.reasoningText)
}
@Test
fun testMessageComplete_withoutNewReasoning_persistsEmptyReasoning() {
// Simulates: message A reasoned, then a fast reply B with no reasoning.
val previousReasoning = "old reasoning trace"
val state =
ChatUiState(
currentSessionId = "session-1",
)
val staleStreaming =
StreamingState(
streamingMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "prev",
reasoningText = previousReasoning,
isStreaming = true,
),
isReasoning = true,
reasoningText = previousReasoning,
)
val startEvent = WsEvent.MessageStart(sessionId = "session-1")
val startResult =
ChatWsEventReducer.reduce(
state = state,
streamingState = staleStreaming,
event = startEvent,
currentSessionId = "session-1",
)
val completeResult =
ChatWsEventReducer.reduce(
state = startResult.state,
streamingState = startResult.streamingState,
event = WsEvent.MessageComplete(text = "fast reply", sessionId = "session-1"),
currentSessionId = "session-1",
)
// Issue #755: no reasoning tokens arrived for message B, so the
// finalized message must carry EMPTY reasoning — never the stale trace.
val persisted = completeResult.state.messages.last()
assertEquals("fast reply", persisted.content)
assertEquals("", persisted.reasoningText)
val persistEffect = completeResult.effects.filterIsInstance<ReducerEffect.PersistMessage>().firstOrNull()
assertTrue(persistEffect != null)
assertEquals("", persistEffect?.message?.reasoningText)
}
@Test
fun testMessageStart_prunesCompletedSubagents() {
val completedSubagent =