* 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
@@ -68,12 +68,14 @@ object EventParser {
|
||||
}
|
||||
|
||||
"reasoning.available" -> {
|
||||
WsEvent.ReasoningAvailable(sessionId)
|
||||
val text = payload?.get("text") as? String
|
||||
WsEvent.ReasoningAvailable(sessionId, text)
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
WsEvent.MessageComplete(text, sessionId)
|
||||
val reasoning = payload?.get("reasoning") as? String
|
||||
WsEvent.MessageComplete(text, sessionId, reasoning)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
|
||||
@@ -37,11 +37,26 @@ sealed class WsEvent {
|
||||
|
||||
data class ReasoningAvailable(
|
||||
val sessionId: String?,
|
||||
/**
|
||||
* Full reasoning text, emitted once reasoning finishes streaming
|
||||
* (`reasoning.available` payload `text`). The gateway sends the
|
||||
* complete trace here even when per-token `reasoning.delta` events
|
||||
* were throttled, dropped, or wiped by a mid-turn tool.start — the
|
||||
* client must use it as the authoritative fill for the card.
|
||||
*/
|
||||
val text: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageComplete(
|
||||
val text: String,
|
||||
val sessionId: String?,
|
||||
/**
|
||||
* Full reasoning trace for the completed message, carried in the
|
||||
* `message.complete` payload (`reasoning` key). Authoritative
|
||||
* fallback when per-token `reasoning.delta` events never arrived
|
||||
* (e.g. a tool call wiped the streaming buffer mid-turn).
|
||||
*/
|
||||
val reasoning: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
|
||||
@@ -55,6 +55,25 @@ class ChatStreamingController(
|
||||
streamingState.update { it.copy(isReasoning = false, reasoningText = "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears ONLY the throttled token buffers (and their flush timers) —
|
||||
* never touches `streamingMessage`, `isReasoning` or `reasoningText`.
|
||||
*
|
||||
* Used at tool.start: the reducer keeps the streaming message + its
|
||||
* reasoning alive across the tool call (issue #771), and the VM must
|
||||
* not undo that by resetting the shared streaming state. The buffers
|
||||
* themselves are drained (flushPendingReasoning) before reduce, so a
|
||||
* buffer-only clear is safe and prevents stale deltas from re-flushing.
|
||||
*/
|
||||
fun clearStreamingBuffers() {
|
||||
streamingBuffer.clear()
|
||||
thinkingBuffer.clear()
|
||||
reasoningBuffer.clear()
|
||||
lastFlushMs = 0L
|
||||
lastThinkingFlushMs = 0L
|
||||
lastReasoningFlushMs = 0L
|
||||
}
|
||||
|
||||
/** Resets buffers and starts a fresh streaming message (called on MessageStart). */
|
||||
fun beginStreamingMessage() {
|
||||
resetStreaming()
|
||||
|
||||
@@ -58,6 +58,111 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
private const val TAG = "ChatViewModel"
|
||||
private const val MESSAGE_PAGE_SIZE = 150
|
||||
|
||||
/**
|
||||
* Canonical comparison key for a tool message's result payload.
|
||||
*
|
||||
* WS tool messages store the full tool.complete payload
|
||||
* (`{"tool_id":..., "name":..., "args":..., "result": {...}}`) while REST
|
||||
* transcript rows store just the result object (`{"output":..., "exit_code":...}`).
|
||||
* This key normalizes both sides — preferring the `result` field when present,
|
||||
* and treating int/float JSON numbers as equal — so the two representations of
|
||||
* the SAME tool call can be matched regardless of position or pagination.
|
||||
* Returns null for unparseable content (no match possible).
|
||||
*/
|
||||
internal fun canonicalToolResultKey(content: String): String? {
|
||||
val element =
|
||||
try {
|
||||
OkHttpProvider.json.parseToJsonElement(content)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
fun canon(e: kotlinx.serialization.json.JsonElement): String =
|
||||
when (e) {
|
||||
is kotlinx.serialization.json.JsonObject ->
|
||||
e.entries.sortedBy { it.key }.joinToString("|") { "${it.key}=${canon(it.value)}" }
|
||||
is kotlinx.serialization.json.JsonArray ->
|
||||
e.joinToString(",") { canon(it) }
|
||||
is kotlinx.serialization.json.JsonPrimitive -> {
|
||||
// Canonicalize ALL numbers through double, collapsing int/float
|
||||
// spellings of the same value (0 vs 0.0 → "i0", 0.5 → "d0.5").
|
||||
val s = e.content
|
||||
val d = s.toDoubleOrNull()
|
||||
if (d != null) {
|
||||
if (d == d.toLong().toDouble()) "i${d.toLong()}" else "d$d"
|
||||
} else {
|
||||
"s$s"
|
||||
}
|
||||
}
|
||||
}
|
||||
return when (element) {
|
||||
is kotlinx.serialization.json.JsonObject ->
|
||||
element["result"]?.let { canon(it) } ?: canon(element)
|
||||
else -> canon(element)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [a] and [b] are the same logical message (the WS-persisted and
|
||||
* REST-persisted copies of one row — they carry different ids, see #771).
|
||||
* Tool messages match on their normalized result payload; other roles on
|
||||
* exact content.
|
||||
*/
|
||||
internal fun sameLogicalMessage(
|
||||
a: ChatMessage,
|
||||
b: ChatMessage,
|
||||
): Boolean {
|
||||
if (a.role != b.role) return false
|
||||
if (a.role == MessageRole.TOOL) {
|
||||
val ka = canonicalToolResultKey(a.content)
|
||||
val kb = canonicalToolResultKey(b.content)
|
||||
return ka != null && ka == kb
|
||||
}
|
||||
return a.content == b.content
|
||||
}
|
||||
|
||||
/**
|
||||
* Room accumulates BOTH the WS-persisted copy (UUID id, rich tool payload,
|
||||
* tool name) and the REST-persisted copy (`rest-` id, result-only payload,
|
||||
* no tool name) of every message. Painting the cache verbatim renders the
|
||||
* same call twice. Drop the `rest-` copy whenever a WS copy of the same
|
||||
* logical message exists (issue #771).
|
||||
*/
|
||||
internal fun dedupeCachedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
val rest = messages.filter { it.id.startsWith("rest-") }
|
||||
if (rest.isEmpty()) return messages
|
||||
val nonRest = messages.filterNot { it.id.startsWith("rest-") }
|
||||
if (nonRest.isEmpty()) return messages
|
||||
val keepRest = rest.filter { restMsg -> nonRest.none { sameLogicalMessage(it, restMsg) } }
|
||||
return (nonRest + keepRest).sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* A transcript reload must NOT yank live WS bubbles the server has not
|
||||
* persisted yet. The gateway stores a tool row only once the tool
|
||||
* COMPLETES server-side, so a reload that lands while a tool is running
|
||||
* (app background/foreground mid-turn, reconnect re-resume, pull-refresh)
|
||||
* returns a page without the tool row — replacing the list outright made
|
||||
* the in-flight tool bubble vanish and left tool.complete with no RUNNING
|
||||
* message to update (issue #771).
|
||||
*
|
||||
* Merge instead of replace: append any current message the REST page does
|
||||
* not already cover (checked by id AND logical content via
|
||||
* [sameLogicalMessage]) and keep chronological order.
|
||||
*/
|
||||
internal fun mergeTranscriptWithLive(
|
||||
restMessages: List<ChatMessage>,
|
||||
currentMessages: List<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val restIds = restMessages.map { it.id }.toSet()
|
||||
val liveTail =
|
||||
currentMessages.filter { old ->
|
||||
old.id !in restIds && restMessages.none { sameLogicalMessage(it, old) }
|
||||
}
|
||||
if (liveTail.isEmpty()) return restMessages
|
||||
return (restMessages + liveTail).sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
@@ -538,8 +643,12 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
is WsEvent.ToolStart -> {
|
||||
// Reset streaming state when a tool starts
|
||||
streamingController.resetStreaming()
|
||||
// Issue #771: the reducer keeps the streaming message (and its
|
||||
// reasoning) alive across the tool call so the finalized answer
|
||||
// retains the thinking card. Only the token buffers are cleared
|
||||
// here — resetStreaming() would wipe streamingMessage +
|
||||
// reasoningText and re-introduce the mid-turn reasoning vanish.
|
||||
streamingController.clearStreamingBuffers()
|
||||
}
|
||||
|
||||
is WsEvent.RpcResult -> {
|
||||
@@ -1581,7 +1690,7 @@ class ChatViewModel(
|
||||
|
||||
private fun loadCachedMessages(sessionId: String): Job =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val cachedMessages = repo.loadMessages(sessionId)
|
||||
val cachedMessages = dedupeCachedMessages(repo.loadMessages(sessionId))
|
||||
_uiState.update { state ->
|
||||
// Only paint if still showing this session AND no fresher server
|
||||
// page has landed yet (a fast REST fetch must not be clobbered
|
||||
@@ -1609,8 +1718,12 @@ class ChatViewModel(
|
||||
}
|
||||
_uiState.update { state ->
|
||||
if (state.currentSessionId != sessionId) return@update state
|
||||
// Merge, don't replace: a reload mid-turn must not
|
||||
// drop live WS bubbles (running tool call, streaming
|
||||
// answer) the server hasn't persisted yet. Issue #771.
|
||||
val merged = mergeTranscriptWithLive(chatMessages, state.messages)
|
||||
state.copy(
|
||||
messages = chatMessages,
|
||||
messages = merged,
|
||||
isLoading = false,
|
||||
hasOlderMessages = serverOffset > 0 && chatMessages.isNotEmpty(),
|
||||
isLoadingOlder = false,
|
||||
@@ -1807,6 +1920,16 @@ class ChatViewModel(
|
||||
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
|
||||
_uiState.update { current ->
|
||||
if (current.currentSessionId != sessionId) return@update current
|
||||
// Issue #771: the sync merge was dropping the
|
||||
// newest tool bubble — the incoming REST page
|
||||
// didn't include it yet (server persists tool rows
|
||||
// at completion, but the sync offset may predate
|
||||
// that), and the fragile toolName/content match
|
||||
// consumed the WRONG incoming tool for an existing
|
||||
// one, leaving the newest WS tool with no match
|
||||
// → dropped. Use sameLogicalMessage (canonical
|
||||
// result-key match) and always preserve any WS
|
||||
// message that has no REST counterpart.
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val mergedList = mutableListOf<ChatMessage>()
|
||||
|
||||
@@ -1820,20 +1943,22 @@ class ChatViewModel(
|
||||
mergedList.add(existing)
|
||||
}
|
||||
} else {
|
||||
// WS message (UUID id, no server index):
|
||||
// match by canonical content, not fragile
|
||||
// toolName/content equality.
|
||||
val matchIdx =
|
||||
unmatchedIncoming.indexOfFirst { inc ->
|
||||
inc.role == existing.role && (
|
||||
inc.content == existing.content ||
|
||||
(
|
||||
existing.role == MessageRole.TOOL &&
|
||||
inc.toolName != null &&
|
||||
inc.toolName == existing.toolName
|
||||
)
|
||||
)
|
||||
sameLogicalMessage(inc, existing)
|
||||
}
|
||||
if (matchIdx >= 0) {
|
||||
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
|
||||
// Prefer the WS copy (richer payload,
|
||||
// real tool name) when available.
|
||||
val inc = unmatchedIncoming[matchIdx]
|
||||
mergedList.add(existing)
|
||||
unmatchedIncoming.removeAt(matchIdx)
|
||||
} else {
|
||||
// No REST counterpart (server hasn't
|
||||
// persisted yet) — KEEP the WS message.
|
||||
mergedList.add(existing)
|
||||
}
|
||||
}
|
||||
@@ -2027,10 +2152,36 @@ class ChatViewModel(
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
// and mixed cache state make positional mapping misalign, leaving
|
||||
// the newest call with a null name → generic "tool" bubble). When a
|
||||
// match is found the live message is reused wholesale (same id +
|
||||
// toolName + rich payload), so persistence upserts the same row
|
||||
// instead of accumulating a second `rest-` copy in Room. Issue #771.
|
||||
val liveToolByResult = linkedMapOf<String, ChatMessage>()
|
||||
_uiState.value.messages
|
||||
.filter { it.role == MessageRole.TOOL }
|
||||
.sortedBy { it.id.startsWith("rest-") } // prefer live WS copies
|
||||
.forEach { msg ->
|
||||
canonicalToolResultKey(msg.content)?.let { key ->
|
||||
liveToolByResult.putIfAbsent(key, msg)
|
||||
}
|
||||
}
|
||||
|
||||
val baseUrl = AuthManager.getBaseUrl()
|
||||
val token = AuthManager.getToken().orEmpty()
|
||||
|
||||
return messages.mapIndexed { index, msg ->
|
||||
val mapped = mutableListOf<ChatMessage>()
|
||||
// The gateway stores a reasoning-model's thinking as its OWN assistant
|
||||
// row (content = "", reasoning = trace) directly before the answer row.
|
||||
// Rendering that as a standalone empty assistant bubble is the
|
||||
// "reasoning box in a separate bubble" artifact — fold it into the
|
||||
// next assistant message with content instead. Issue #771.
|
||||
var pendingReasoning: String? = null
|
||||
|
||||
messages.forEachIndexed { index, msg ->
|
||||
val role =
|
||||
when (msg.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
@@ -2047,13 +2198,21 @@ class ChatViewModel(
|
||||
?: System.currentTimeMillis()
|
||||
|
||||
val rawContent = msg.contentText
|
||||
val reasoning =
|
||||
val rowReasoning =
|
||||
if (msg.reasoningText.isNotBlank()) {
|
||||
msg.reasoningText
|
||||
} else {
|
||||
existingReasoningMap[rawContent]?.reasoningText.orEmpty()
|
||||
}
|
||||
|
||||
// Reasoning-only assistant row (the gateway's split storage of a
|
||||
// reasoning turn): stash the trace, skip the empty bubble, and
|
||||
// attach it to the next assistant message that has content.
|
||||
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isNotBlank()) {
|
||||
pendingReasoning = rowReasoning
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
var finalContent = rawContent
|
||||
var attachments: List<Attachment>? = null
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
@@ -2081,16 +2240,54 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
id = "rest-$sessionId-$globalIndex",
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = reasoning,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
val finalReasoning =
|
||||
if (rowReasoning.isNotBlank()) {
|
||||
rowReasoning
|
||||
} else if (role == MessageRole.ASSISTANT && pendingReasoning != null) {
|
||||
pendingReasoning.also { pendingReasoning = null }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry no tool name. When the
|
||||
// result payload matches a live WS tool message, reuse it whole —
|
||||
// keeps the real name, the rich WS payload, AND the same id so
|
||||
// Room upserts instead of accumulating a duplicate `rest-` row.
|
||||
if (role == MessageRole.TOOL) {
|
||||
canonicalToolResultKey(rawContent)?.let { key ->
|
||||
liveToolByResult[key]?.let { live ->
|
||||
mapped.add(live)
|
||||
return@forEachIndexed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
id = "rest-$sessionId-$globalIndex",
|
||||
role = role,
|
||||
content = finalContent,
|
||||
reasoningText = finalReasoning,
|
||||
attachments = attachments,
|
||||
timestamp = timestamp,
|
||||
isStreaming = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A reasoning-only row with no following answer (interrupted turn):
|
||||
// don't drop the trace — attach it to the last assistant message.
|
||||
if (pendingReasoning != null) {
|
||||
val lastAssistantIdx = mapped.indexOfLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistantIdx >= 0) {
|
||||
val target = mapped[lastAssistantIdx]
|
||||
if (target.reasoningText.isBlank()) {
|
||||
mapped[lastAssistantIdx] = target.copy(reasoningText = pendingReasoning!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mapped
|
||||
}
|
||||
|
||||
// ── Issue #724: attach host-path MEDIA: files as real attachments ────
|
||||
|
||||
@@ -247,11 +247,31 @@ object ChatWsEventReducer {
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.ReasoningAvailable,
|
||||
): ReducerResult =
|
||||
ReducerResult(
|
||||
state = state,
|
||||
streamingState = streamingState.copy(isReasoning = true),
|
||||
)
|
||||
): ReducerResult {
|
||||
// The gateway sends the FULL reasoning trace in reasoning.available
|
||||
// (payload `text`) once reasoning finishes streaming. When the text
|
||||
// is present, use it as the authoritative fill — it survives even if
|
||||
// per-token reasoning.delta events were dropped or wiped by a
|
||||
// mid-turn tool.start. Attach it to the live streaming message so
|
||||
// message.complete carries it into the finalized bubble.
|
||||
val text = event.text
|
||||
return if (!text.isNullOrBlank() && streamingState.streamingMessage != null) {
|
||||
ReducerResult(
|
||||
state = state,
|
||||
streamingState =
|
||||
streamingState.copy(
|
||||
isReasoning = true,
|
||||
reasoningText = text,
|
||||
streamingMessage = streamingState.streamingMessage.copy(reasoningText = text),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
ReducerResult(
|
||||
state = state,
|
||||
streamingState = streamingState.copy(isReasoning = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── MessageComplete ───────────────────────────────────────────────
|
||||
|
||||
@@ -261,12 +281,15 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.MessageComplete,
|
||||
): ReducerResult {
|
||||
val streaming = streamingState.streamingMessage
|
||||
// Prefer the authoritative reasoning carried in the message.complete
|
||||
// payload (the gateway's assembled full trace), then fall back to
|
||||
// whatever reasoning.delta deltas accumulated during streaming.
|
||||
// Fixes: a mid-turn tool.start wiping the streaming reasoning used
|
||||
// to leave the finalized bubble with no reasoning card at all.
|
||||
val reasoning =
|
||||
if (streamingState.reasoningText.isNotBlank()) {
|
||||
streamingState.reasoningText
|
||||
} else {
|
||||
streaming?.reasoningText ?: ""
|
||||
}
|
||||
event.reasoning
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: streamingState.reasoningText.ifBlank { streaming?.reasoningText ?: "" }
|
||||
val msg =
|
||||
streaming?.copy(
|
||||
content = event.text,
|
||||
@@ -392,11 +415,29 @@ object ChatWsEventReducer {
|
||||
effects.add(ReducerEffect.PersistMessage(toolMessage, sid))
|
||||
}
|
||||
|
||||
// Issue #771: KEEP the streaming state across a tool call instead of
|
||||
// resetting it. A reasoning-model turn streams its thinking BEFORE the
|
||||
// tool starts (content is still empty here) — wiping the streaming
|
||||
// message at tool.start made the reasoning bubble vanish mid-turn and
|
||||
// left the finalized answer with no reasoning card. The stream stays
|
||||
// alive; post-tool message.delta continues the SAME message and
|
||||
// message.complete finalizes it with the reasoning intact.
|
||||
// When an orphan WAS sealed (interim text before the tool), the
|
||||
// streaming message is done — null it, but keep the reasoning text in
|
||||
// the shared state so the post-tool fallback message still picks it up.
|
||||
val finalStreamingState =
|
||||
if (orphanToPersist != null) {
|
||||
streamingState.copy(streamingMessage = null)
|
||||
} else {
|
||||
streamingState
|
||||
}
|
||||
|
||||
val parsedTodos = if (event.name == "todo") extractTodosFromMap(event.data) else null
|
||||
val nextTodos = parsedTodos ?: newState.todos
|
||||
|
||||
return ReducerResult(
|
||||
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
|
||||
streamingState = finalStreamingState,
|
||||
effects = effects,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user