From a11761f120be25927c373449cc4d5f66a0e6b06a Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:56:41 +0300 Subject: [PATCH] fix(chat): skip empty assistant tool-call placeholder rows in REST transcript (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../hermescontrol/ui/chat/ChatViewModel.kt | 17 +++++++++---- .../chat/fullbleed/FullBleedAgentMessage.kt | 24 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 851ec72a..b0ea9aa1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 1145954a..4ded4389 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -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