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 =