Empty bubbles on iOS when the assistant only called tools
The iOS client kept showing blank assistant bubbles in the middle of a multi-step turn. Pull-to-refresh fixed it. Notifications fired fine. The text was clearly in the database — it just wasn't rendering in the right bubble.
What was happening
The server creates a new "bubble" (an assistant message row) every
time the agent produces a new chunk of output. The intent was: each
visible answer block gets its own bubble so it's easy to scan in
the UI. The function responsible was ensure_new_bubble, called
once per AssistantMessage in the streaming loop.
The problem: a single agent turn often looks like
AssistantMessage(blocks=[ToolUseBlock("Bash", ...)])
AssistantMessage(blocks=[ToolUseBlock("Edit", ...)])
AssistantMessage(blocks=[TextBlock("here's what I did")])
ensure_new_bubble was firing on every AssistantMessage, including
the tool-only ones. That meant the server was creating empty rows
for the tool calls and a fourth row for the actual text. The iOS
client, streaming over SSE, would dutifully receive a bubble
event after every tool use and switch its render target — then the
text would land in the latest bubble, but the client had already
finalized the previous (empty) ones in its UI state.
What I found
The right rule isn't "split on every AssistantMessage." It's "split when the current bubble already has text and there's more text coming." Tool calls without surrounding text should stay attached to whatever bubble is currently active.
The fix
Track a bubble_has_text flag in the streaming loop. Rotate to a
new bubble only when the next message contains a TextBlock and the
current bubble has already received text.
bubble_has_text = False
async for msg in agent_messages:
if isinstance(msg, AssistantMessage):
for block in msg.blocks:
if isinstance(block, TextBlock):
if bubble_has_text:
new_id = await history.add_message(
conv_id, "assistant", ""
)
await sse.send("bubble", str(new_id))
current_msg_id = new_id
bubble_has_text = False
await history.append_to_message(
pk, current_msg_id, block.text
)
bubble_has_text = True
elif isinstance(block, ToolUseBlock):
# tool uses do NOT flip the flag
await sse.send("tool_use", block.dict())
Two invariants fall out:
- The initial empty bubble (created by
/chatfor the first AssistantMessage) gets reused on first text instead of being abandoned. No orphan empty rows. - A turn that ends in nothing but tool uses (rare but possible) doesn't leave behind a phantom assistant bubble.
What I'd do differently
The original ensure_new_bubble was named for what it was supposed
to do (make sure a new bubble exists) rather than what it
checked (is the current bubble committed yet). Renaming it to
rotate_bubble_if_needed and giving it a precondition made the
intent obvious. When a helper's name doesn't tell you the
condition it's guarding, the bug it'll cause is usually "fired at
the wrong time."