* fix(chat): stop duplicating interim commentary before tool calls (issue #842) Interim commentary the agent narrates before each tool call rendered TWICE. Two mechanisms, both now covered: 1. The old tool.start orphan seal + message.complete's full text: when the complete payload repeats the commentary, the final bubble duplicated it. onMessageComplete now strips the sealed-orphan prefix (tracked in StreamingState) from the final text. 2. On-device capture + DB forensics (2026-08-08) exposed the real repeat offender: the app seals the RAW streamed text — which carries leading blank lines the model emits before its narration — while the backend persists a CLEANED copy (leading whitespace stripped). A transcript reload ~10s later merged the REST page, exact-content matching failed, and the cleaned REST copy rendered on top of the live bubble. sameLogicalMessage now compares trimmed content, so the sealed live bubble is covered by the REST row. Also: - session.interrupt seals the in-flight streaming message before clearing streaming state, so interrupting a commentary turn keeps the partial text - regression tests cover both turn shapes (commentary in the final row → stripped; absent → kept) and the whitespace-drift reload case * fix(chat): match tool rows by gateway call id, drop reasoning-echo dupes (#842) REST transcript rows carry tool_call_id; live WS bubbles keep tool_id from tool.start. Match tool rows on that 1:1 identity so MCP/web rows (stored as raw <untrusted_tool_result> text with no JSON key) dedupe during the post-turn transcript merge instead of ghosting after the final message. reasoning.available echo (narration text == message body, sent ~40ms before each tool.start) no longer overwrites the real reasoning trace, so narration bubbles don't render their own text twice (body + Reasoning card). Room v5 migration adds tool_call_id; regression tests use captured on-device payload shapes. * fix(chat): flush throttled tokens before orphan seal + prefix-safe dedupe (#842) The tool.start seal raced the 33ms throttled token flush: deltas landing just before the transition stayed in the buffer, so the sealed narration orphan came out truncated (on-device capture: orphan ended '...chickpea', backend persisted '...chickpea goodness:') and failed the logical-match against the clean REST row — the commentary ghosted on the next transcript sync. - ChatStreamingController.flushPendingTokens(): force-flushes the throttled TOKEN buffer on MessageStart/MessageComplete/MessageDone/ToolStart so the reducer seals the COMPLETE narration. - sameLogicalMessage: accept prefix-covering (both sides >=40 chars) so a residual seal drift can never re-add a substantial narration. - Regression tests: controller flush + truncated-orphan prefix match. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ab42e19b7f
commit
adde03a4de
14 files changed
+780
-38
No files matched your search
@@ -43,6 +43,44 @@ class ChatStreamingControllerTest {
|
||||
assertEquals("AB", streamingState.value.streamingMessage?.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flushPendingTokens_flushesThrottledTokensBeforeSeal() =
|
||||
runTest {
|
||||
// Issue #842: a delta landing <33ms before tool.start stays in the
|
||||
// throttled buffer; flushing the tokens before the reducer seals the
|
||||
// orphan must push the COMPLETE narration onto the message.
|
||||
val uiState = MutableStateFlow(ChatUiState())
|
||||
val streamingState =
|
||||
MutableStateFlow(
|
||||
StreamingState(
|
||||
streamingMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
isStreaming = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
val controller = controller(this, uiState, streamingState, isTestEnvironment = { false })
|
||||
|
||||
controller.handleMessageToken(WsEvent.MessageToken("tool's loaded! 🔍 now searchin'", "session"))
|
||||
// First delta flushes immediately.
|
||||
assertEquals("tool's loaded! 🔍 now searchin'", streamingState.value.streamingMessage?.content)
|
||||
|
||||
// Second delta arrives <33ms later -> throttled, still buffered.
|
||||
controller.handleMessageToken(WsEvent.MessageToken(" for the best hummus", "session"))
|
||||
assertEquals("tool's loaded! 🔍 now searchin'", streamingState.value.streamingMessage?.content)
|
||||
|
||||
// The tool.start transition forces the buffered tokens out so the
|
||||
// sealed orphan carries the complete narration.
|
||||
controller.flushPendingTokens()
|
||||
|
||||
assertEquals(
|
||||
"tool's loaded! 🔍 now searchin' for the best hummus",
|
||||
streamingState.value.streamingMessage?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetStreaming_clearsReasoningBufferSoStaleReasoningDoesNotResurrect() =
|
||||
runTest {
|
||||
|
||||
@@ -82,6 +82,50 @@ class ChatToolDedupeTest {
|
||||
assertTrue(sameLogicalMessage(ws, rest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_whitespaceDrift_stillSame() {
|
||||
// Issue #842: the sealed live bubble holds the RAW streamed text
|
||||
// (leading blank lines), the backend row is CLEANED — same logical
|
||||
// message, must not duplicate on reload.
|
||||
val live = ChatMessage(role = MessageRole.ASSISTANT, content = "\n\noooh fresh angle this time 🤤")
|
||||
val rest = ChatMessage(role = MessageRole.ASSISTANT, content = "oooh fresh angle this time 🤤")
|
||||
assertTrue(sameLogicalMessage(live, rest))
|
||||
assertFalse(sameLogicalMessage(live, ChatMessage(role = MessageRole.ASSISTANT, content = "different reply")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_sealRaceTruncatedNarration_prefixCovered() {
|
||||
// Issue #842 (on-device capture): the seal raced the throttled flush and
|
||||
// the orphan missed the last deltas — it ends "...dreamy chickpea" while
|
||||
// the backend persisted the COMPLETE narration "...dreamy chickpea
|
||||
// goodness:". The orphan must count as covered (strict prefix, both
|
||||
// sides substantial) so the reload doesn't ghost the commentary.
|
||||
val orphan =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content =
|
||||
"\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
|
||||
" — gimme dat creamy dreamy chickpea",
|
||||
)
|
||||
val rest =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content =
|
||||
"tool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
|
||||
" — gimme dat creamy dreamy chickpea goodness:",
|
||||
)
|
||||
assertTrue(sameLogicalMessage(orphan, rest))
|
||||
|
||||
// A SHORT truncated prefix must NOT swallow a longer message that merely
|
||||
// starts with it ("ok" is not the same message as "okay bestie...").
|
||||
assertFalse(
|
||||
sameLogicalMessage(
|
||||
ChatMessage(role = MessageRole.ASSISTANT, content = "ok"),
|
||||
ChatMessage(role = MessageRole.ASSISTANT, content = "okay bestie, 3 searches comin' up!! 🔍"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_differentRoles_notSame() {
|
||||
val ws = ChatMessage(role = MessageRole.TOOL, content = wsToolContent)
|
||||
@@ -155,6 +199,34 @@ class ChatToolDedupeTest {
|
||||
assertEquals("terminal", merged[1].toolName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_sealedCommentaryWithLeadingWhitespace_noDuplicate() {
|
||||
// Issue #842 (on-device capture 2026-08-08): the app seals the RAW
|
||||
// streamed commentary — which can carry leading blank lines the model
|
||||
// emits before its narration — while the backend persists a CLEANED
|
||||
// copy. A reload merge with exact-content matching treated them as
|
||||
// different messages and added the REST copy on top: the commentary
|
||||
// duplicated ~10s after the stream ended. Trim-based matching covers
|
||||
// the sealed live bubble, so no second copy renders.
|
||||
val liveOrphan =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "\n\noooh fresh angle this time — the **Lebanese method** and even hummus fatteh 🤤",
|
||||
timestamp = 3,
|
||||
)
|
||||
val restRow =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "oooh fresh angle this time — the **Lebanese method** and even hummus fatteh 🤤",
|
||||
id = "rest-s-19",
|
||||
timestamp = 3,
|
||||
)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan))
|
||||
|
||||
assertEquals("cleaned REST row covers the whitespace-drifting live bubble", 1, merged.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_matchingUser_keepsLiveOnlyMetadata() {
|
||||
val live = ChatMessage(role = MessageRole.USER, content = "redirect", isStreaming = true, timestamp = 1)
|
||||
@@ -436,4 +508,107 @@ class ChatToolDedupeTest {
|
||||
assertEquals(listOf("retry", "retry"), merged.map { it.content })
|
||||
assertSame(live, merged.last())
|
||||
}
|
||||
|
||||
// ── Issue #842: MCP/web tool rows (raw `<untrusted_tool_result>` text) ──
|
||||
//
|
||||
// REAL captured shapes (on-device 2026-08-08): the live WS bubble holds
|
||||
// the full tool.complete payload (JSON), while the REST transcript row
|
||||
// stores the SAME call's payload as RAW blob TEXT — not JSON, so content
|
||||
// canonicalization can never produce a key. The gateway `tool_call_id`
|
||||
// (present on BOTH sides) is the 1:1 identity that closes the gap.
|
||||
|
||||
private val wsMcpToolPayload =
|
||||
"""{"tool_id":"call_00_1GgRcqEFKA2TR0EkdkN85844","name":"mcp__ddgs__search_text","args":{"query":"Amman weather today"},"duration_s":3.2,"result":{"result":"<untrusted_tool_result source=\"mcp__ddgs__search_text\">\nThe following content was retrieved from an external source.\n{\"title\":\"Amman weather\"}\n</untrusted_tool_result>"}}"""
|
||||
|
||||
private val restMcpToolBlob =
|
||||
"""<untrusted_tool_result source="mcp__ddgs__search_text">
|
||||
The following content was retrieved from an external source.
|
||||
{"title":"Amman weather"}
|
||||
</untrusted_tool_result>"""
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_mcpBlobRestRow_matchesByCallId() {
|
||||
val live =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = wsMcpToolPayload,
|
||||
toolName = "mcp__ddgs__search_text",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
)
|
||||
val rest =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = restMcpToolBlob,
|
||||
id = "rest-s-3",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
)
|
||||
assertTrue(sameLogicalMessage(live, rest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_differentCallIds_notSameEvenWithSameBlob() {
|
||||
val live = ChatMessage(role = MessageRole.TOOL, content = wsMcpToolPayload, toolCallId = "call_00_aaa")
|
||||
val rest = ChatMessage(role = MessageRole.TOOL, content = restMcpToolBlob, toolCallId = "call_00_bbb")
|
||||
assertFalse(sameLogicalMessage(live, rest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameLogicalMessage_callIdMissing_fallsBackToContentCanonical() {
|
||||
// Old cached rows may lack the call id — terminal-style content
|
||||
// canonicalization must still match.
|
||||
val ws = ChatMessage(role = MessageRole.TOOL, content = wsToolContent, toolName = "terminal")
|
||||
val rest = ChatMessage(role = MessageRole.TOOL, content = restToolContent)
|
||||
assertTrue(sameLogicalMessage(ws, rest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeTranscriptWithLive_mcpToolRows_collapseToOne() {
|
||||
// Issue #842 on-device repro: the REST page carries the same MCP
|
||||
// search call as raw blob text; with the call id present the merge
|
||||
// must NOT add a second tool bubble.
|
||||
val live =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = wsMcpToolPayload,
|
||||
toolName = "mcp__ddgs__search_text",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
timestamp = 2,
|
||||
)
|
||||
val rest =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = restMcpToolBlob,
|
||||
id = "rest-s-3",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
timestamp = 2,
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live))
|
||||
// One copy survives (the merge keeps the REST row as server truth and
|
||||
// drops the covered live bubble — in the real flow mapServerMessages
|
||||
// already substituted the live row before this merge, preserving the
|
||||
// tool name + rich payload). The invariant: never two bubbles.
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals(MessageRole.TOOL, merged.single().role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dedupeCached_mcpBlobRestRow_droppedWhenWsCopyExists() {
|
||||
val wsTool =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = wsMcpToolPayload,
|
||||
toolName = "mcp__ddgs__search_text",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
)
|
||||
val restTool =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = restMcpToolBlob,
|
||||
id = "rest-s-3",
|
||||
toolCallId = "call_00_1GgRcqEFKA2TR0EkdkN85844",
|
||||
)
|
||||
val deduped = dedupeCachedMessages(listOf(wsTool, restTool))
|
||||
assertEquals(1, deduped.size)
|
||||
assertEquals(wsTool, deduped.single())
|
||||
}
|
||||
}
|
||||
@@ -839,7 +839,7 @@ class ChatViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToolExecution_finalizesPreviousStreamingMessage() =
|
||||
fun testToolExecution_sealsInterimTextAndStripsCompletePrefix() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
@@ -850,6 +850,9 @@ class ChatViewModelTest {
|
||||
mockEventsFlow.emit(WsEvent.ToolStart("calculator", mapOf("input" to "2+2")))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Issue #842: the interim text is sealed as its own bubble at
|
||||
// tool.start (desktop parity), the stream tail clears, and the
|
||||
// sealed id is tracked for the complete-prefix strip.
|
||||
// messages[0] = "Session created" system message
|
||||
assertEquals(
|
||||
"Calculating sum",
|
||||
@@ -861,12 +864,29 @@ class ChatViewModelTest {
|
||||
viewModel.uiState.value.messages[1]
|
||||
.role,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.messages[1].isStreaming)
|
||||
assertEquals(
|
||||
MessageRole.TOOL,
|
||||
viewModel.uiState.value.messages[2]
|
||||
.role,
|
||||
)
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
assertEquals(
|
||||
listOf(viewModel.uiState.value.messages[1].id),
|
||||
viewModel.streamingState.value.sealedOrphanIds,
|
||||
)
|
||||
|
||||
// Finalize: the complete payload repeats the sealed commentary as
|
||||
// a prefix — the final bubble strips it, so each line appears once.
|
||||
mockEventsFlow.emit(WsEvent.MessageComplete("Calculating sum = 4", sessionId))
|
||||
advanceUntilIdle()
|
||||
val assistant =
|
||||
viewModel.uiState.value.messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(2, assistant.size)
|
||||
assertEquals("Calculating sum", assistant[0].content)
|
||||
assertEquals(" = 4", assistant[1].content)
|
||||
assertFalse(assistant[1].isStreaming)
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -610,6 +610,44 @@ class ChatWsEventReducerTest {
|
||||
assertEquals(1, result.state.messages.count { it.role == MessageRole.ASSISTANT })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReasoningAvailable_narrationEcho_notAttachedAsReasoning() {
|
||||
// Issue #842 on-device capture (2026-08-08): the gateway re-sends the
|
||||
// agent's interim NARRATION as reasoning.available ~40ms before each
|
||||
// tool.start — payload text == the streaming message body. Attaching
|
||||
// the echo as reasoningText would render every narration bubble's
|
||||
// text twice (body + Reasoning card). Real reasoning never equals the
|
||||
// message content, so the echo is skipped and the reasoning.delta
|
||||
// trace stays authoritative.
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val realReasoning = "The user wants 3 searches"
|
||||
val stream =
|
||||
StreamingState(
|
||||
streamingMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "\n\n✅ search #2 done!! Amman's cookin 🔥",
|
||||
isStreaming = true,
|
||||
reasoningText = realReasoning,
|
||||
),
|
||||
reasoningText = realReasoning,
|
||||
)
|
||||
|
||||
// Echo arrives with the CLEANED narration text (backend strips the
|
||||
// leading blank lines the raw stream carried).
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
stream,
|
||||
WsEvent.ReasoningAvailable("session-1", "✅ search #2 done!! Amman's cookin 🔥"),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
// The echo must NOT overwrite the real reasoning trace.
|
||||
assertEquals(realReasoning, result.streamingState.reasoningText)
|
||||
assertEquals(realReasoning, result.streamingState.streamingMessage?.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_reasoningPayloadField_isAuthoritativeFallback() {
|
||||
// Even if reasoning.delta events were entirely lost (throttled/wipe),
|
||||
@@ -630,11 +668,10 @@ class ChatWsEventReducerTest {
|
||||
}
|
||||
|
||||
@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.
|
||||
fun testToolStart_withInterimText_sealsOrphanAndTracksIt() {
|
||||
// Issue #842: interim commentary BEFORE a tool call is sealed as its
|
||||
// own bubble (desktop parity) and its id tracked in streamingState so
|
||||
// message.complete can strip the repeated prefix from the final text.
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val streaming =
|
||||
StreamingState(
|
||||
@@ -657,12 +694,160 @@ class ChatWsEventReducerTest {
|
||||
"session-1",
|
||||
)
|
||||
|
||||
assertEquals(2, result.state.messages.size) // sealed orphan + tool
|
||||
// Sealed orphan + tool row.
|
||||
assertEquals(2, result.state.messages.size)
|
||||
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(MessageRole.TOOL, result.state.messages[1].role)
|
||||
// Stream tail cleared, orphan id tracked for the complete-strip.
|
||||
assertEquals(null, result.streamingState.streamingMessage)
|
||||
// Reasoning preserved for the post-tool message
|
||||
assertEquals(listOf(result.state.messages[0].id), result.streamingState.sealedOrphanIds)
|
||||
// Reasoning preserved for the post-tool message.
|
||||
assertEquals("thinking...", result.streamingState.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMultiToolCommentaryTurn_noDuplicateInterimText() {
|
||||
// Regression for issue #842 (m57 repro, 2026-08-08): a turn where the
|
||||
// agent narrates commentary before EACH tool call, and the final
|
||||
// message.complete text repeats that commentary as a prefix. Each
|
||||
// commentary line used to render TWICE — once as the sealed orphan
|
||||
// bubble, once inside the final bubble. Now the final bubble strips
|
||||
// the sealed prefix: every line appears exactly once.
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val start =
|
||||
ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
|
||||
|
||||
val line1 = "say less — full scrub it is 🧹 lemme pull context around each hit so the patches land clean:"
|
||||
val line2 = "context's clear — patching all 4 spots now:"
|
||||
val line3 = "all 4 patches landed ✅ — final verification sweep to make sure zero sandalwood survives anywhere:"
|
||||
val answer = "Done — zero sandalwood left. 🫡"
|
||||
// The final row is the concatenation of the streamed text — the same
|
||||
// characters the sealed orphans hold (no extra separators).
|
||||
val fullText = line1 + line2 + line3 + answer
|
||||
|
||||
// Token accumulation is ViewModel-side; the reducer sees flushed content.
|
||||
// After a tool.start seals the orphan, post-tool deltas recreate the
|
||||
// streaming message via the controller fallback — mirror that here.
|
||||
fun withContent(
|
||||
content: String,
|
||||
streamingState: StreamingState,
|
||||
) = streamingState.copy(
|
||||
streamingMessage =
|
||||
streamingState.streamingMessage?.copy(content = content)
|
||||
?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
// 1. commentary line 1, then tool call 1 → orphan sealed + tracked
|
||||
var result =
|
||||
ChatWsEventReducer.reduce(
|
||||
start.state,
|
||||
withContent(line1, start.streamingState),
|
||||
WsEvent.ToolStart("grep", mapOf("args_text" to "sandalwood"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(1, result.state.messages.count { it.role == MessageRole.ASSISTANT })
|
||||
assertEquals(1, result.state.messages.count { it.role == MessageRole.TOOL })
|
||||
|
||||
// 2. commentary line 2, then tool call 2
|
||||
result =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
withContent(line2, result.streamingState),
|
||||
WsEvent.ToolStart("patch", mapOf("args_text" to "4 spots"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
// 3. commentary line 3, then tool call 3
|
||||
result =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
withContent(line3, result.streamingState),
|
||||
WsEvent.ToolStart("grep", mapOf("args_text" to "verify"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
val orphans = result.state.messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(listOf(line1, line2, line3), orphans.map { it.content })
|
||||
assertEquals(3, result.state.messages.count { it.role == MessageRole.TOOL })
|
||||
assertEquals(orphans.map { it.id }, result.streamingState.sealedOrphanIds)
|
||||
|
||||
// 4. message.complete repeats the commentary as a prefix → the final
|
||||
// bubble strips it and shows only the answer.
|
||||
result =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
result.streamingState,
|
||||
WsEvent.MessageComplete(text = fullText, sessionId = "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
val allAssistant = result.state.messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(
|
||||
"each commentary line exactly once (sealed bubble) + the stripped answer",
|
||||
listOf(line1, line2, line3, answer),
|
||||
allAssistant.map { it.content },
|
||||
)
|
||||
assertFalse(allAssistant.last().isStreaming)
|
||||
assertEquals(null, result.streamingState.streamingMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMultiToolCommentaryTurn_completeWithoutCommentary_keepsOrphansAndFinal() {
|
||||
// Issue #842 variant caught on-device (2026-08-08): some turns emit
|
||||
// commentary ONLY as streamed deltas + message.interim — the final
|
||||
// message.complete text does NOT contain it. The sealed orphan
|
||||
// bubbles must survive and the final bubble must keep its full text
|
||||
// (no stripping when there is no prefix overlap).
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val start =
|
||||
ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
|
||||
|
||||
val line1 = "okie let's go!! 🌸 first up — **hummus**:"
|
||||
val line2 = "yum yum, got a whole buffet 🥙 **search #2**:"
|
||||
val summary = "done!! all three searches landed 🎯 here's your summary."
|
||||
|
||||
fun withContent(
|
||||
content: String,
|
||||
streamingState: StreamingState,
|
||||
) = streamingState.copy(
|
||||
streamingMessage =
|
||||
streamingState.streamingMessage?.copy(content = content)
|
||||
?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
var result =
|
||||
ChatWsEventReducer.reduce(
|
||||
start.state,
|
||||
withContent(line1, start.streamingState),
|
||||
WsEvent.ToolStart("mcp__ddgs__search_text", mapOf("query" to "hummus"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
result =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
withContent(line2, result.streamingState),
|
||||
WsEvent.ToolStart("mcp__ddgs__search_text", mapOf("query" to "amman"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
// Final text does NOT contain the commentary → no stripping.
|
||||
result =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
result.streamingState,
|
||||
WsEvent.MessageComplete(text = summary, sessionId = "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
val allAssistant = result.state.messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(listOf(line1, line2, summary), allAssistant.map { it.content })
|
||||
assertEquals(null, result.streamingState.streamingMessage)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user