fix(chat): skip empty assistant tool-call placeholder rows in REST transcript (#953)

* fix(chat): skip empty assistant tool-call placeholder rows in REST transcript

Non-reasoning models (e.g. gemini-3.5-flash) store a tool-call turn as an
empty assistant row (content = "", no reasoning) with a tool_calls list.
The REST transcript returns these verbatim, and mapServerMessages was only
filtering empty assistant rows that carried reasoning text (the issue #771
thinking-model case). With no reasoning, the filter check failed and the
placeholder rows fell through — rendering as empty bubbles with Copy buttons
in the chat list.

Fix: skip ANY empty assistant row regardless of whether reasoning is present.
If it carries reasoning, stash it for folding (issue #771 path unchanged).
If it doesn't, it's a pure tool-call placeholder — silently drop it.
No user-visible content is lost: these rows never held displayable text.

Verified on device (hermes37 emulator): session 'Friendly greeting #9' had
10 ghost empty bubbles; after fix the transcript shows only real messages.

Issue: #956

* fix(chat): blank-content guard in FullBleedAgentMessage prose renderer

Defense-in-depth for the ghost-bubble fix (#953): never render an empty
prose block (blank text + lone Copy button) if a placeholder row ever
slips past upstream mapping. Streaming messages keep rendering so the
live cursor survives until the first delta lands.

Verified on device: cold start of 'Friendly greeting #9' renders only
real content — no empty bubbles.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-22 13:56:41 +03:00
1 parent 5153e1572f
commit a11761f120
2 files changed
+17 -4

No files matched your search

@@ -2929,11 +2929,18 @@ class ChatViewModel(
existingReasoningMap[rawContent]?.reasoningText.orEmpty() existingReasoningMap[rawContent]?.reasoningText.orEmpty()
} }
// Reasoning-only assistant row (the gateway's split storage of a // Empty assistant row — two cases stored by the gateway:
// reasoning turn): stash the trace, skip the empty bubble, and // 1. Reasoning-only: thinking-model split storage (content = "",
// attach it to the next assistant message that has content. // reasoning = trace). Stash the trace and fold it into the
if (role == MessageRole.ASSISTANT && rawContent.isBlank() && rowReasoning.isNotBlank()) { // next assistant message that has content (issue #771).
// 2. Tool-call placeholder: non-reasoning models emit content = ""
// with tool_calls metadata and no reasoning. These carry no
// user-visible text and must not render as empty bubbles
// (issue #956).
if (role == MessageRole.ASSISTANT && rawContent.isBlank()) {
if (rowReasoning.isNotBlank()) {
pendingReasoning = rowReasoning pendingReasoning = rowReasoning
}
return@forEachIndexed return@forEachIndexed
} }
@@ -103,6 +103,11 @@ internal fun FullBleedAgentMessage(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
} }
// Defense-in-depth: never render an empty prose block (blank bubble +
// lone Copy button). Blank settled rows are tool-call placeholders that
// slipped through upstream mapping; streaming keeps rendering so the
// live cursor survives until the first delta lands.
if (message.content.isNotBlank() || message.isStreaming) {
SelectionContainer { SelectionContainer {
MarkdownText( MarkdownText(
text = message.content, text = message.content,
@@ -113,6 +118,7 @@ internal fun FullBleedAgentMessage(
onImageClick = onImageClick, onImageClick = onImageClick,
) )
} }
}
// Render inline attachments (mirrors ChatBubble so agent-delivered // Render inline attachments (mirrors ChatBubble so agent-delivered
// media — images, files — shows in full-bleed mode too). // media — images, files — shows in full-bleed mode too).