Back to blog
FILE 0xDA·EMPTY BUBBLES ON IOS WHEN THE ASSISTANT ONLY CALLED TOOLS

Empty bubbles on iOS when the assistant only called tools

April 20, 2026 · sse, ios, bugs

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:

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."