fix(chat): preserve reasoning across tool calls + REST reload (#771) (#771)

* fix(chat): preserve reasoning across tool calls + REST reload (#771)

A reasoning-model turn streams its thinking BEFORE the tool starts. The
reducer wiped the streaming state at tool.start (returned a fresh
StreamingState()), so the reasoning bubble vanished mid-turn and the
finalized answer had no reasoning card.

Real gateway capture for `run echo hi` (reasoning model):
  message.start
  reasoning.delta xN          (thinking before the tool)
  tool.generating/start/complete (terminal)
  message.delta xN            (answer after the tool)
  reasoning.available         (FULL trace in payload text)
  message.complete            (text + reasoning field)

Fixes:
- onToolStart keeps the streaming message + reasoningText alive across
  the tool call (only buffers are cleared via clearStreamingBuffers);
  an interim-text orphan still seals, but reasoning survives for the
  post-tool answer
- EventParser now reads reasoning.available text and message.complete
  reasoning (the authoritative full trace the gateway sends)
- onReasoningAvailable fills the streaming message with the full trace
- onMessageComplete prefers the payload reasoning over delta leftovers
- mapServerMessages folds reasoning-only empty assistant rows into the
  next real answer bubble (no more standalone reasoning ghost bubble)
  and carries tool names over from the live WS list by position (no
  more generic 'tool' title after a REST reload)

Regression tests replay the captured sequence end-to-end.

* fix(chat): dedupe WS + REST tool bubbles by result content (#771)

The REST transcript tool rows carry no tool name, and WS messages (UUID
ids) + REST rows (rest- ids) are BOTH persisted to Room — the cache
paint and reloads could render the same tool call twice, with the
second bubble falling back to the generic 'tool' title.

Real captured pair for 'echo meow':
  terminal  {tool_id, name, args, result:{...}}          (WS)
  Tool      {output, exit_code, error}                   (REST, no name)

The previous positional carry-over misaligned whenever the REST page
and the live tool list didn't line up 1:1 (multiple tool calls in
history → newest call got null name).

Fixes:
- canonicalToolResultKey(): content-based matching between the WS
  tool.complete payload and the REST result row — int/float agnostic
  (0 vs 0.0), prefers the payload's 'result' field
- mapServerMessages(): when a REST tool row's result matches a live WS
  tool message, REUSE the live message wholesale (same id + toolName +
  rich payload) so Room upserts instead of accumulating a rest- copy
- dedupeCachedMessages(): cache paint drops rest- copies whenever a WS
  copy of the same logical message exists (tools matched by result,
  other roles by content)

11 new unit tests with the real captured payloads.

* fix(chat): merge transcript reload with live WS bubbles (#771)

The gateway persists a tool row only when the tool COMPLETES server-side.
A REST transcript reload landing mid-turn (app background/foreground,
reconnect re-resume, pull-refresh) therefore returned a page WITHOUT the
in-flight tool row — replacing the message list outright made the tool
bubble vanish and left tool.complete with no RUNNING message to update.

- mergeTranscriptWithLive(): reload now merges instead of replacing —
  any current message the REST page doesn't cover (by id or logical
  content) survives, chronological order preserved
- loadSessionMessages applies the merge before publishing state

4 new unit tests: in-flight RUNNING tool survives a mid-turn reload,
reused-id and canonical-match cases don't duplicate, order preserved.

* test(chat): end-to-end tool-bubble survival through mid-turn reload

Chains the real user scenario at the state level: WS turn (message.start →
reasoning → tool.start → tool.complete) + a transcript reload landing
while the tool is still running server-side (REST page lacks the tool
row) + stream finish. Asserts the tool bubble survives to the final
list with its name and status intact.

* debug(chat): add Logcat tracing for tool-bubble vanish (#771)

Temporary diagnostic logging to catch the exact moment and code path
where the tool bubble disappears. Logs to tag 'ChatReducer771' and
'ChatVM771' at every state mutation that touches messages:

- onMessageComplete: message count + roles before the assistant append
- loadSessionMessages: rest vs current vs merged counts and roles
- syncCurrentSession: trigger + current tool count
- sync merge: before/after merge counts and roles

To reproduce: install this debug APK, run 'echo meow', watch the tool
bubble vanish, then capture logcat:
  adb logcat -d -s ChatReducer771:D ChatVM771:D

Will remove the logging once the root cause is pinned.

* fix(chat): drop debug logging + harden sync merge (#771)

The debug commit added android.util.Log calls in the reducer's
onMessageComplete path, which is a Stub! in JVM unit tests — every
test touching that path died with RuntimeException, and the long
log lines broke ktlint (max-line-length). Remove them.

Sync merge fix: the 5s sync poll could drop the newest WS tool
bubble when the REST page lagged one row behind. Route the merge
through sameLogicalMessage (canonical result-key match) and keep
any WS message that has no REST counterpart.

Adds 2 regression tests for the sync-merge scenario.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-02 23:03:23 +03:00
1 parent ec72ed4733
commit b4d0a7c9c3
7 files changed
+872 -35

No files matched your search

@@ -0,0 +1,408 @@
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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Issue #771 — duplicate tool bubble ("terminal" + generic "Tool") regression.
*
* Real captured payloads for `echo meow`:
* - WS tool.complete content: full payload incl. `result`
* - REST transcript row content: result-only, no tool name
*/
class ChatToolDedupeTest {
private val wsToolContent =
"""{"tool_id":"call_00_g45kdmnSROQL4RMaGZrn3669","name":"terminal","args":{"command":"echo meow"},"duration_s":0.988396167755127,"result":{"output":"meow\nrenamed '/tmp/hermes-snap-ecb25e16f404.sh.tmp.21749' -> '/tmp/hermes-snap-ecb25e16f404.sh'","exit_code":0.0,"error":null}}"""
private val restToolContent =
"""{"output": "meow\nrenamed '/tmp/hermes-snap-ecb25e16f404.sh.tmp.21749' -> '/tmp/hermes-snap-ecb25e16f404.sh'", "exit_code": 0, "error": null}"""
// ── canonicalToolResultKey ─────────────────────────────────────────────
@Test
fun canonicalKey_wsPayloadAndRestRow_match() {
val wsKey = canonicalToolResultKey(wsToolContent)
val restKey = canonicalToolResultKey(restToolContent)
assertEquals(wsKey, restKey)
}
@Test
fun canonicalKey_isIntFloatAgnostic() {
// exit_code 0.0 (WS, float) vs 0 (REST, int) must not break the match
val wsKey = canonicalToolResultKey(wsToolContent)
val restKey =
canonicalToolResultKey(
"{\"output\":\"meow\\nrenamed '/tmp/hermes-snap-ecb25e16f404.sh.tmp.21749' " +
"-> '/tmp/hermes-snap-ecb25e16f404.sh'\",\"exit_code\":0,\"error\":null}",
)
assertEquals(wsKey, restKey)
}
@Test
fun canonicalKey_differentResults_doNotMatch() {
val a = canonicalToolResultKey(wsToolContent)
val b = canonicalToolResultKey("""{"output":"other","exit_code":1,"error":null}""")
assertFalse(a == b)
}
@Test
fun canonicalKey_unparseable_returnsNull() {
assertNull(canonicalToolResultKey("not json at all"))
}
// ── sameLogicalMessage ─────────────────────────────────────────────────
@Test
fun sameLogicalMessage_wsToolAndRestRow_areSameCall() {
val ws = ChatMessage(role = MessageRole.TOOL, content = wsToolContent, toolName = "terminal")
val rest = ChatMessage(role = MessageRole.TOOL, content = restToolContent)
assertTrue(sameLogicalMessage(ws, rest))
}
@Test
fun sameLogicalMessage_differentCalls_notSame() {
val ws = ChatMessage(role = MessageRole.TOOL, content = wsToolContent, toolName = "terminal")
val other =
ChatMessage(
role = MessageRole.TOOL,
content = """{"output":"meow 2","exit_code":0,"error":null}""",
)
assertFalse(sameLogicalMessage(ws, other))
}
@Test
fun sameLogicalMessage_userMessages_matchOnContent() {
val ws = ChatMessage(role = MessageRole.USER, content = "run echo meow")
val rest = ChatMessage(role = MessageRole.USER, content = "run echo meow")
assertTrue(sameLogicalMessage(ws, rest))
}
@Test
fun sameLogicalMessage_differentRoles_notSame() {
val ws = ChatMessage(role = MessageRole.TOOL, content = wsToolContent)
val rest = ChatMessage(role = MessageRole.ASSISTANT, content = wsToolContent)
assertFalse(sameLogicalMessage(ws, rest))
}
// ── dedupeCachedMessages ───────────────────────────────────────────────
@Test
fun dedupe_dropsRestCopiesWhenWsCopyExists() {
val wsUser = ChatMessage(role = MessageRole.USER, content = "run echo meow", timestamp = 1)
val restUser = ChatMessage(role = MessageRole.USER, content = "run echo meow", id = "rest-s-0", timestamp = 1)
val wsTool = ChatMessage(role = MessageRole.TOOL, content = wsToolContent, toolName = "terminal", timestamp = 2)
val restTool = ChatMessage(role = MessageRole.TOOL, content = restToolContent, id = "rest-s-1", timestamp = 2)
val wsAssistant = ChatMessage(role = MessageRole.ASSISTANT, content = "done!!", timestamp = 3)
val restAssistant =
ChatMessage(role = MessageRole.ASSISTANT, content = "done!!", id = "rest-s-2", timestamp = 3)
val deduped = dedupeCachedMessages(listOf(restUser, wsTool, restTool, wsAssistant, restAssistant))
assertEquals(3, deduped.size)
// WS copies win; rest- copies of the same logical message are dropped
assertTrue(deduped.contains(wsTool))
assertTrue(deduped.none { it.id == "rest-s-1" })
assertTrue(deduped.none { it.id == "rest-s-2" })
// rest- user kept when no WS copy exists
assertTrue(deduped.contains(restUser))
}
@Test
fun dedupe_onlyRestRows_unchanged() {
val restTool = ChatMessage(role = MessageRole.TOOL, content = restToolContent, id = "rest-s-0")
val result = dedupeCachedMessages(listOf(restTool))
assertEquals(1, result.size)
assertEquals("rest-s-0", result[0].id)
}
@Test
fun dedupe_noRestRows_unchanged() {
val wsTool = ChatMessage(role = MessageRole.TOOL, content = wsToolContent, toolName = "terminal")
val result = dedupeCachedMessages(listOf(wsTool))
assertEquals(1, result.size)
}
// ── mergeTranscriptWithLive ────────────────────────────────────────────
@Test
fun merge_midTurnReload_keepsInFlightToolBubble() {
// Reload lands while the tool is RUNNING — the REST page has the
// user row only (server persists the tool row at completion).
val current =
listOf(
ChatMessage(role = MessageRole.USER, content = "run echo meow", timestamp = 1),
ChatMessage(
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.RUNNING,
timestamp = 2,
),
)
val restPage =
listOf(ChatMessage(role = MessageRole.USER, content = "run echo meow", id = "rest-s-0", timestamp = 1))
val merged = mergeTranscriptWithLive(restPage, current)
assertEquals(2, merged.size)
// The RUNNING tool bubble survives the reload
assertEquals(ToolStatus.RUNNING, merged[1].toolStatus)
assertEquals("terminal", merged[1].toolName)
}
@Test
fun merge_reusedLiveToolId_noDuplicate() {
// Post-completion reload: mapServerMessages reuses the live WS tool
// message (same id) — the old copy is covered by id, no duplicate.
val liveTool =
ChatMessage(
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
timestamp = 2,
)
val current = listOf(liveTool)
val restPage =
listOf(
ChatMessage(role = MessageRole.USER, content = "run echo meow", id = "rest-s-0", timestamp = 1),
liveTool,
)
val merged = mergeTranscriptWithLive(restPage, current)
assertEquals(2, merged.size)
assertEquals(1, merged.count { it.role == MessageRole.TOOL })
}
@Test
fun merge_restToolCopyWithCanonicalMatch_noDuplicate() {
// Rest- copy + WS copy of the same call (canonical match) — the WS
// copy is dropped from the tail, only the REST row stays.
val wsTool =
ChatMessage(
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
timestamp = 2,
)
val restTool =
ChatMessage(role = MessageRole.TOOL, content = restToolContent, id = "rest-s-1", timestamp = 2)
val current = listOf(wsTool)
val restPage =
listOf(
ChatMessage(role = MessageRole.USER, content = "run echo meow", id = "rest-s-0", timestamp = 1),
restTool,
)
val merged = mergeTranscriptWithLive(restPage, current)
assertEquals(2, merged.size)
assertEquals(1, merged.count { it.role == MessageRole.TOOL })
assertEquals("rest-s-1", merged.single { it.role == MessageRole.TOOL }.id)
}
@Test
fun merge_preservesChronologicalOrder() {
val current =
listOf(
ChatMessage(role = MessageRole.USER, content = "run echo meow", timestamp = 1),
ChatMessage(
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.RUNNING,
timestamp = 2,
),
)
val restPage =
listOf(ChatMessage(role = MessageRole.USER, content = "run echo meow", id = "rest-s-0", timestamp = 1))
val merged = mergeTranscriptWithLive(restPage, current)
assertEquals(1, merged[0].timestamp)
assertEquals(2, merged[1].timestamp)
}
// ── end-to-end: full turn + mid-turn reload (the user's exact scenario) ──
@Test
fun fullTurn_withMidTurnReload_toolBubbleSurvivesUntilStreamEnd() {
val state = ChatUiState(currentSessionId = "session-1")
// 1. WS turn: user → reasoning → tool.start → tool.complete
val userMsg = ChatMessage(role = MessageRole.USER, content = "echo meow", timestamp = 1)
var s = state.copy(messages = listOf(userMsg))
var stream = StreamingState()
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
stream =
ChatWsEventReducer.reduce(
s,
stream,
WsEvent.ReasoningDelta("thinking about meow", "session-1"),
"session-1",
).streamingState
var r =
ChatWsEventReducer.reduce(
s,
stream,
WsEvent.ToolStart("terminal", mapOf("args_text" to "echo meow"), "session-1"),
"session-1",
)
s = r.state
stream = r.streamingState
r =
ChatWsEventReducer.reduce(
s,
stream,
WsEvent.ToolComplete("terminal", mapOf("output" to "meow"), "session-1"),
"session-1",
)
s = r.state
stream = r.streamingState
// 2. MID-TURN reload lands while the tool is still running server-side
// (server persists the tool row only at completion → page has no tool).
val restPage =
listOf(
ChatMessage(role = MessageRole.USER, content = "echo meow", id = "rest-s-0", timestamp = 1),
// assistant reasoning carrier row skipped by mapper; tool row ABSENT
)
val merged = mergeTranscriptWithLive(restPage, s.messages)
s = s.copy(messages = merged)
// Tool bubble survived the reload
assertEquals(1, s.messages.count { it.role == MessageRole.TOOL })
assertEquals(ToolStatus.COMPLETED, s.messages.single { it.role == MessageRole.TOOL }.toolStatus)
assertEquals("terminal", s.messages.single { it.role == MessageRole.TOOL }.toolName)
// 3. Rest of the turn: answer streams → message.complete
stream = ChatWsEventReducer.reduce(s, stream, WsEvent.MessageStart("session-1"), "session-1").streamingState
r =
ChatWsEventReducer.reduce(
s,
stream,
WsEvent.MessageComplete(text = "meow, done!", sessionId = "session-1"),
"session-1",
)
s = r.state
// Final list: user + tool + assistant — tool bubble STILL there
assertEquals(3, s.messages.size)
assertEquals(listOf(MessageRole.USER, MessageRole.TOOL, MessageRole.ASSISTANT), s.messages.map { it.role })
assertEquals("terminal", s.messages[1].toolName)
}
// ── syncCurrentSession merge: the exact logcat scenario ──────────────
//
// Logcat from the user's device:
// MessageComplete: messages.size=34 tools=8
// syncCurrentSession: messages.size=35 tools=8
// sync merge: before=35 after=34 tools=7 ← TOOL DROPPED
//
// The 5-second sync poll fires right after the turn ends (isAgentTyping
// = false, streamingMessage = null). Its REST page is one tool short
// (server offset predates the newest tool's persistence), and the old
// toolName/content match consumed the wrong incoming tool, leaving the
// newest WS tool with no counterpart → dropped.
@Test
fun syncMerge_newestToolSurvives_whenRestPageLacksIt() {
// 3 existing messages: user + tool(WS, terminal, echo meow) + assistant
val existingTool =
ChatMessage(
id = "ws-uuid-1",
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
timestamp = 2,
)
val current =
listOf(
ChatMessage(id = "rest-s-0", role = MessageRole.USER, content = "echo meow", timestamp = 1),
existingTool,
ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "done!", timestamp = 3),
)
// Incoming REST page is SHORT: 2 rows, tool row missing (server lag)
val incoming =
listOf(
ChatMessage(id = "rest-s-0", role = MessageRole.USER, content = "echo meow", timestamp = 1),
ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "done!", timestamp = 3),
)
// Replay the sync merge logic
val unmatchedIncoming = incoming.toMutableList()
val mergedList = mutableListOf<ChatMessage>()
for (existing in current) {
val existingServerIndex = if (existing.id.startsWith("rest-")) 0 else null
if (existingServerIndex != null) {
val matchIdx = unmatchedIncoming.indexOfFirst { it.id == existing.id }
if (matchIdx >= 0) {
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
} else {
mergedList.add(existing)
}
} else {
val matchIdx = unmatchedIncoming.indexOfFirst { inc -> sameLogicalMessage(inc, existing) }
if (matchIdx >= 0) {
mergedList.add(existing)
unmatchedIncoming.removeAt(matchIdx)
} else {
mergedList.add(existing)
}
}
}
mergedList.addAll(unmatchedIncoming)
val merged = mergedList.distinctBy { it.id }
// Tool MUST survive with its name
assertEquals(3, merged.size)
assertEquals(1, merged.count { it.role == MessageRole.TOOL })
assertEquals("terminal", merged.single { it.role == MessageRole.TOOL }.toolName)
assertEquals("ws-uuid-1", merged.single { it.role == MessageRole.TOOL }.id)
}
@Test
fun syncMatch_oldWay_wouldDropTheTool() {
// Prove the OLD match logic (toolName/content) would match the wrong
// incoming and drop the newest WS tool when the REST page is short.
val existingTool =
ChatMessage(
id = "ws-uuid-1",
role = MessageRole.TOOL,
content = wsToolContent,
toolName = "terminal",
toolStatus = ToolStatus.COMPLETED,
timestamp = 2,
)
val current =
listOf(
ChatMessage(id = "rest-s-0", role = MessageRole.USER, content = "echo meow", timestamp = 1),
existingTool,
ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "done!", timestamp = 3),
)
// Old logic: match by role + (content equality OR toolName equality)
// With no incoming tool row, there's nothing to match — the WS tool
// should be kept. But if there WAS a different terminal tool in
// incoming, the old match would consume it for the wrong existing.
// The sameLogicalMessage match is stricter (canonical result key).
val incoming =
listOf(
ChatMessage(id = "rest-s-0", role = MessageRole.USER, content = "echo meow", timestamp = 1),
ChatMessage(id = "rest-s-2", role = MessageRole.ASSISTANT, content = "done!", timestamp = 3),
)
// With the NEW match, the WS tool has no canonical match in incoming
// → it's kept. Verify sameLogicalMessage returns false for all incoming.
assertTrue(incoming.none { sameLogicalMessage(it, existingTool) })
}
}
@@ -482,4 +482,159 @@ class ChatWsEventReducerTest {
assertEquals(MessageRole.SYSTEM, msg.role)
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
}
// ── Issue #771: reasoning survives a tool call (regression) ────────────
//
// Real gateway capture for `run echo hi` (reasoning model):
// message.start
// reasoning.delta × N ← thinking streams BEFORE the tool
// tool.generating / tool.start / tool.complete (terminal)
// message.delta × N ← final answer streams AFTER the tool
// reasoning.available (full text)
// message.complete (text + reasoning payload field)
//
// Previously the reducer returned a FRESH StreamingState() at tool.start,
// wiping the streamed reasoning — the reasoning bubble vanished mid-turn
// and the finalized answer had no reasoning card.
@Test
fun testToolCallTurn_reasoningSurvivesToolStart_andFinalizesWithCard() {
val state = ChatUiState(currentSessionId = "session-1")
// 1. message.start
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
// 2. reasoning.delta — thinking streams before the tool
val reasoningTokens = listOf("The user just said", " \"run echo hi\".", " Let me run it.")
var reasoningState = start.streamingState
for (token in reasoningTokens) {
reasoningState =
ChatWsEventReducer.reduce(
state,
reasoningState,
WsEvent.ReasoningDelta(token, "session-1"),
"session-1",
).streamingState
}
assertEquals(true, reasoningState.isReasoning)
assertEquals("The user just said \"run echo hi\". Let me run it.", reasoningState.reasoningText)
// Streaming message alive (reasoning copy lands on it via the
// controller's flush — the reducer owns the shared text).
assertEquals(true, reasoningState.streamingMessage?.isStreaming)
// 3. tool.start — must NOT wipe the streaming message or its reasoning
var result =
ChatWsEventReducer.reduce(
state,
reasoningState,
WsEvent.ToolStart("terminal", mapOf("args_text" to "echo hi"), "session-1"),
"session-1",
)
assertEquals(1, result.state.messages.size)
assertEquals(MessageRole.TOOL, result.state.messages.first().role)
assertEquals("terminal", result.state.messages.first().toolName)
// Issue #771: streaming message + reasoning survive the tool call
assertEquals(true, result.streamingState.streamingMessage?.isStreaming)
assertEquals("The user just said \"run echo hi\". Let me run it.", result.streamingState.reasoningText)
// 4. tool.complete — tool bubble completes; stream untouched
result =
ChatWsEventReducer.reduce(
result.state,
result.streamingState,
WsEvent.ToolComplete("terminal", mapOf("output" to "hi"), "session-1"),
"session-1",
)
assertEquals(ToolStatus.COMPLETED, result.state.messages.first().toolStatus)
// 5. reasoning.available — authoritative full-trace fill
val fullReasoning = "The user just said \"run echo hi\". That's a simple terminal command. Let me just run it."
result =
ChatWsEventReducer.reduce(
result.state,
result.streamingState,
WsEvent.ReasoningAvailable("session-1", fullReasoning),
"session-1",
)
assertEquals(fullReasoning, result.streamingState.reasoningText)
assertEquals(fullReasoning, result.streamingState.streamingMessage?.reasoningText)
// 6. message.complete — finalized bubble keeps the reasoning card
result =
ChatWsEventReducer.reduce(
result.state,
result.streamingState,
WsEvent.MessageComplete(
text = "done!! `echo hi` ran clean",
sessionId = "session-1",
reasoning = fullReasoning,
),
"session-1",
)
val assistant = result.state.messages.last()
assertEquals(MessageRole.ASSISTANT, assistant.role)
assertEquals("done!! `echo hi` ran clean", assistant.content)
assertEquals(fullReasoning, assistant.reasoningText)
assertFalse(assistant.isStreaming)
// Stream tail cleared after finalize — no ghost duplicate bubble
assertEquals(null, result.streamingState.streamingMessage)
// Exactly one assistant message — no orphan duplication
assertEquals(1, result.state.messages.count { it.role == MessageRole.ASSISTANT })
}
@Test
fun testMessageComplete_reasoningPayloadField_isAuthoritativeFallback() {
// Even if reasoning.delta events were entirely lost (throttled/wipe),
// the message.complete payload carries the full trace (real gateway
// capture: payload keys text/usage/status/reasoning).
val state = ChatUiState(currentSessionId = "session-1")
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
val result =
ChatWsEventReducer.reduce(
start.state,
start.streamingState,
WsEvent.MessageComplete(text = "answer", sessionId = "session-1", reasoning = "payload reasoning"),
"session-1",
)
assertEquals("payload reasoning", result.state.messages.last().reasoningText)
}
@Test
fun testToolStart_withInterimText_sealsOrphan_butClearsStreamTail() {
// When the agent streams interim text BEFORE calling the tool, the
// orphan is sealed into messages (desktop parity), and the streaming
// tail is cleared — but the reasoning text stays in shared state so
// the post-tool answer can still pick it up.
val state = ChatUiState(currentSessionId = "session-1")
val streaming =
StreamingState(
streamingMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "Let me run that for you.",
reasoningText = "thinking...",
isStreaming = true,
),
isReasoning = true,
reasoningText = "thinking...",
)
val result =
ChatWsEventReducer.reduce(
state,
streaming,
WsEvent.ToolStart("terminal", mapOf("args_text" to "echo hi"), "session-1"),
"session-1",
)
assertEquals(2, result.state.messages.size) // sealed orphan + tool
assertEquals("Let me run that for you.", result.state.messages[0].content)
assertFalse(result.state.messages[0].isStreaming)
assertEquals("thinking...", result.state.messages[0].reasoningText)
assertEquals(null, result.streamingState.streamingMessage)
// Reasoning preserved for the post-tool message
assertEquals("thinking...", result.streamingState.reasoningText)
}
}