feat(#525): render reasoning.delta / reasoning.available stream (#543)

* feat(#525): add ReasoningDelta + ReasoningAvailable WsEvent types

* feat(#525): parse reasoning.delta + reasoning.available; route through reducer + StreamingState

* feat(#525): add reasoning streaming buffer to ChatStreamingController

* feat(#525): dispatch ReasoningDelta/ReasoningAvailable in ChatViewModel

* feat(#525): render collapsible ReasoningIndicator in chat list

* fix(#525): address review — icons+semantics, LocalHermesStatusColors, drop redundant ReasoningAvailable branch

* feat(#525): persist reasoningText on model, entity, mapper + Room migration 3->4

* feat(#525): reducer + streaming carry reasoningText onto message

* feat(#525): render persisted reasoning above each assistant bubble, drop global indicator

* nix: flake

* nix: flake

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-10 23:41:53 +03:00
1 parent d04b74ee0a
commit 53b3966931
17 files changed
+441 -33

No files matched your search

@@ -473,6 +473,55 @@ class ChatViewModelTest {
)
}
@Test
fun testReasoningStreamingFlow() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
// 1 — Reasoning available: block becomes visible (no token yet)
mockEventsFlow.emit(WsEvent.ReasoningAvailable(sessionId))
advanceUntilIdle()
assertTrue(viewModel.streamingState.value.isReasoning)
// 2 — Reasoning delta
mockEventsFlow.emit(WsEvent.ReasoningDelta("Let me think", sessionId))
advanceUntilIdle()
assertEquals("Let me think", viewModel.streamingState.value.reasoningText)
// 3 — Deeper reasoning
mockEventsFlow.emit(WsEvent.ReasoningDelta(" step by step", sessionId))
advanceUntilIdle()
assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText)
// 4 — Thinking still independent
mockEventsFlow.emit(WsEvent.ThinkingDelta("thinking", sessionId))
advanceUntilIdle()
assertTrue(viewModel.streamingState.value.isThinking)
assertEquals("thinking", viewModel.streamingState.value.thinkingText)
// reasoning untouched
assertEquals("Let me think step by step", viewModel.streamingState.value.reasoningText)
// 5 — Complete: reducer finalizes message, attaching reasoning
mockEventsFlow.emit(WsEvent.MessageComplete("The answer is 42", sessionId))
advanceUntilIdle()
assertNull(viewModel.streamingState.value.streamingMessage)
assertEquals(2, viewModel.uiState.value.messages.size)
assertEquals(
"The answer is 42",
viewModel.uiState.value.messages[1].content,
)
// reasoning carried onto the finalized UI message
assertEquals(
"Let me think step by step",
viewModel.uiState.value.messages[1].reasoningText,
)
// reasoning persisted to the entity (survives reload)
val persisted =
fakeRepo.dao.getMessagesForSession(sessionId).first { it.role == "ASSISTANT" }
assertEquals("Let me think step by step", persisted.reasoningText)
}
@Test
fun testToolExecution_finalizesPreviousStreamingMessage() =
runTest {