* 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:
1 parent
d04b74ee0a
commit
53b3966931
17 files changed
+441
-33
No files matched your search
@@ -0,0 +1,59 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatMessageMapperTest {
|
||||
@Test
|
||||
fun entityToUiModelCarriesReasoningText() {
|
||||
val entity =
|
||||
ChatMessageEntity(
|
||||
id = "msg-1",
|
||||
sessionId = "session-a",
|
||||
role = "assistant",
|
||||
content = "Answer",
|
||||
reasoningText = "Let me think step by step",
|
||||
timestamp = 1000L,
|
||||
)
|
||||
|
||||
val ui = entity.toUiModel()
|
||||
|
||||
assertEquals("Let me think step by step", ui.reasoningText)
|
||||
assertEquals("Answer", ui.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiModelToEntityCarriesReasoningText() {
|
||||
val ui =
|
||||
ChatMessage(
|
||||
id = "msg-2",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer",
|
||||
reasoningText = "Chain of thought",
|
||||
timestamp = 2000L,
|
||||
)
|
||||
|
||||
val entity = ui.toEntity("session-b")
|
||||
|
||||
assertEquals("Chain of thought", entity.reasoningText)
|
||||
assertEquals("session-b", entity.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripPreservesReasoningText() {
|
||||
val ui =
|
||||
ChatMessage(
|
||||
id = "msg-3",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer",
|
||||
reasoningText = "r",
|
||||
timestamp = 3000L,
|
||||
)
|
||||
|
||||
val roundTripped = ui.toEntity("s").toUiModel()
|
||||
|
||||
assertEquals("r", roundTripped.reasoningText)
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,48 @@ class EventParserTest {
|
||||
assertEquals("session-2", deltaEvent.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseReasoningDelta_returnsReasoningDeltaEvent() {
|
||||
val response =
|
||||
createJsonRpcResponse(
|
||||
jsonrpc = "2.0",
|
||||
id = null,
|
||||
result = null,
|
||||
error = null,
|
||||
method = "event",
|
||||
params =
|
||||
mapOf(
|
||||
"type" to "reasoning.delta",
|
||||
"payload" to mapOf("text" to "reasoning token", "session_id" to "session-r1"),
|
||||
),
|
||||
)
|
||||
val event = EventParser.parse(response)
|
||||
assertTrue(event is WsEvent.ReasoningDelta)
|
||||
val deltaEvent = event as WsEvent.ReasoningDelta
|
||||
assertEquals("reasoning token", deltaEvent.token)
|
||||
assertEquals("session-r1", deltaEvent.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseReasoningAvailable_returnsReasoningAvailableEvent() {
|
||||
val response =
|
||||
createJsonRpcResponse(
|
||||
jsonrpc = "2.0",
|
||||
id = null,
|
||||
result = null,
|
||||
error = null,
|
||||
method = "event",
|
||||
params =
|
||||
mapOf(
|
||||
"type" to "reasoning.available",
|
||||
"payload" to mapOf("session_id" to "session-r1"),
|
||||
),
|
||||
)
|
||||
val event = EventParser.parse(response)
|
||||
assertTrue(event is WsEvent.ReasoningAvailable)
|
||||
assertEquals("session-r1", (event as WsEvent.ReasoningAvailable).sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseClarifyRequest_returnsClarifyRequestEvent() {
|
||||
val response =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user