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
+27 -14

No files matched your search

@@ -2929,11 +2929,18 @@ class ChatViewModel(
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
// Empty assistant row — two cases stored by the gateway:
// 1. Reasoning-only: thinking-model split storage (content = "",
// reasoning = trace). Stash the trace and fold it into the
// 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
}
return@forEachIndexed
}
@@ -103,15 +103,21 @@ internal fun FullBleedAgentMessage(
Spacer(modifier = Modifier.height(6.dp))
}
SelectionContainer {
MarkdownText(
text = message.content,
textColor = textColor,
isStreaming = message.isStreaming,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
// 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 {
MarkdownText(
text = message.content,
textColor = textColor,
isStreaming = message.isStreaming,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
}
// Render inline attachments (mirrors ChatBubble so agent-delivered