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