* 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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user