From 3a96edd4c125842905e7e4f4f3dfe7f62c3cc519 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:14:35 +0300 Subject: [PATCH] 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 --- .../ui/chat/ChatStreamingController.kt | 4 + .../ui/chat/ChatWsEventReducer.kt | 11 ++- .../ui/chat/ChatStreamingControllerTest.kt | 49 +++++++++++ .../ui/chat/ChatWsEventReducerTest.kt | 84 +++++++++++++++++++ 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt index 1b02b821..6b816da5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatStreamingController.kt @@ -49,6 +49,10 @@ class ChatStreamingController( lastFlushMs = 0L lastThinkingFlushMs = 0L lastReasoningFlushMs = 0L + // Issue #755: the shared streaming state must not carry the previous + // message's reasoning into the next one. The finalized message keeps + // its own copy (persisted to Room), so clearing here is safe. + streamingState.update { it.copy(isReasoning = false, reasoningText = "") } } /** Resets buffers and starts a fresh streaming message (called on MessageStart). */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index ae802ecc..00b2427f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -146,7 +146,10 @@ object ChatWsEventReducer { ChatMessage( role = MessageRole.ASSISTANT, content = "", - reasoningText = streamingState.reasoningText, + // Issue #755: never seed a new message with the previous + // message's reasoning — the card must start blank and only + // fill once real reasoning deltas arrive. + reasoningText = "", isStreaming = true, ) val effects = mutableListOf() @@ -178,8 +181,10 @@ object ChatWsEventReducer { val newStreamingState = StreamingState( streamingMessage = msg, - isReasoning = streamingState.isReasoning, - reasoningText = streamingState.reasoningText, + // Issue #755: the new message starts with a clean reasoning + // slate — no isReasoning flag, no inherited reasoning text. + isReasoning = false, + reasoningText = "", ) val newState = preState val sid = newState.currentSessionId diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt index 8793af62..a9d0ee2e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatStreamingControllerTest.kt @@ -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, diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 9aeb5ccc..ca0d2c67 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -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().firstOrNull() + assertTrue(persistEffect != null) + assertEquals("", persistEffect?.message?.reasoningText) + } + @Test fun testMessageStart_prunesCompletedSubagents() { val completedSubagent =