← Writing

OpenClaw

Why Does OpenClaw Sometimes Send Pieces and Sometimes Rewrite One Message?

Why does streaming sometimes edit one bubble and sometimes send several messages? Follow a checklist with code through OpenClaw previews, chunking, coalescing, and final delivery.

Let's continue the hypothetical scene from the earlier articles. Rather than letting the assistant edit the meeting checklist directly, you ask: “Show me the proposal first, preferably with a short configuration example.”

This time, your attention shifts from the file to the chat window.

Sometimes half a sentence appears and the same message keeps growing, as though someone were still typing inside it. Other times, several bubbles arrive: an explanation, some code, and a final reminder. Why does the response look different when the question is essentially the same?

The obvious explanation is that the model changed how it streams. But between generated text and a visible channel message sits a separate delivery layer. It must distinguish additions from complete snapshots, choose where to split, decide whether small pieces should be combined, and avoid sending everything again at the end.

This article uses OpenClaw v2026.5.22, focusing on the embedded Pi subscription handlers and generic block-reply pipeline, with that version's Telegram preview implementation as a concrete example. Other channels and newer releases can differ; the same transport should not be assumed everywhere. Streaming reference

Both look like streaming, but they are different messages

First, separate the two visual experiences.

One is preview updating: a visible message is created and its content is repeatedly edited. In the Telegram path examined here, initial creation uses sendMessage; once a message identifier is available, updates use editMessageText. The user sees growing text without necessarily receiving a new bubble for every fragment. Telegram preview transport

The other is block delivery. As the assistant writes, OpenClaw organizes some output into blocks and sends them as ordinary channel messages. A later block may become another message. Streaming here means progressively delivering readable content, not forwarding every model token directly to the channel.

Consequently, a setting named block cannot be interpreted without its location. On channels supporting previews, block-style preview updates and enabling ordinary block replies are different switches. The documentation explicitly distinguishes them; actual mapping still depends on the channel adapter. Finding a field with the right name is not enough. Preview and block-reply distinction

Model events pass through delta recognition and visible-content processing before preview editing or ordinary block delivery; the paths need not run simultaneously
One growing bubble and several arriving bubbles can come from different delivery paths.

For the checklist proposal, previews are closer to “show progress, then leave a complete response.” Block delivery is closer to receiving a long answer in installments. Both still require finalization. Text appearing on screen does not, by itself, establish that final delivery succeeded.

When the model sends the full text, do not append it to the full text

A small but practical problem in streaming is determining what the text in an event represents.

Suppose the program has accumulated “The second item,” and text_end then contains “The second item should become two steps.” Blindly appending the new event would repeat the opening.

The subscription handler's resolveAssistantTextChunk() addresses this case. Ordinary text_delta events use the delta. When an ending event contains complete text starting with what has already accumulated, the helper returns only the missing suffix. If the content is identical to the accumulated text, or a shorter prefix of it, nothing is appended. Text-fragment handling

The local experiment checked precisely this: with hello accumulated and hello world in the ending event, the function returns only world. With hello world already present, it returns an empty string.

This helper is not a universal text-diff algorithm, however. If a provider changes earlier text, counting newly added characters cannot handle every case. Later visible-output logic compares cleaned text with the previous output. If the new text no longer starts with the old text, it marks the update as replace; the corresponding path also resets the block buffer. Visible-text updates

When investigating repetition or missing characters, establish whether the input is a delta, an accumulated snapshot, or a replacement before adjusting chunk sizes. All three are strings, but their meanings differ. This layer also handles visible-content, directive, and reasoning boundaries; raw model events are not assumed to be ready for direct display.

Code blocks need care, but a long answer cannot wait forever

Once text is organized, the next decision is when to release a piece.

Sending every few characters floods the conversation before the proposal is complete. Waiting for the entire response can make a long task look unresponsive.

EmbeddedBlockChunker balances minimum length, maximum length, and preferred breakpoints. Small amounts of text can remain buffered. When conditions allow, it looks for paragraph, newline, or other suitable boundaries. Excessively long content still needs splitting when no convenient boundary exists. A forced final drain releases the tail even when it is shorter than the normal minimum. Chunker implementation

At this layer, thresholds use JavaScript string length, not model tokens or network bytes. Reaching the minimum also does not guarantee an immediate channel message: available breakpoints, flush mode, and downstream coalescing can all affect timing.

The configuration example introduces another problem. If a Markdown code fence is cut in half, the next message may begin halfway through the code without an opening fence, changing how it renders.

The chunker recognizes fences. When a long code block must be split, it closes the current fence and reopens it in the following piece, retaining the language marker. Fence recognition

I deliberately used a very small maximum to split ten lines of configuration-style code into three outputs. Each output had opening and closing fences, but a breakpoint still landed inside a statement. That distinction matters: the chunker protects Markdown presentation structure. It does not parse programming-language syntax or promise that each bubble contains independently runnable code. Copying just one piece can still produce an incomplete snippet.

Why combine pieces after splitting them?

The chunker decides where content can be divided. Delivery has another concern: ten individually valid but tiny messages can still be unpleasant to read.

The generic pipeline therefore includes BlockReplyCoalescer. It buffers compatible text pieces and decides when to hand them to the sender based on length, idle gaps, or an explicit forced flush. An idle gap means that new block input has paused; it does not prove that the model finished the entire task. Block coalescer

The two stages serve different purposes. One makes long output divisible; the other controls how those pieces arrive in the channel. A visible message can contain several upstream blocks. Conversely, the channel's own length and formatting constraints may require further processing of the outgoing content.

Chunking handles length and Markdown boundaries, coalescing buffers compatible pieces, and the send pipeline preserves ordering and records successful content; final flushing releases short tails
Chunking manages content boundaries, coalescing manages delivery cadence, and the pipeline manages ordering and send records.

Not everything should become one text buffer. The coalescer considers reply-target changes, voice flags, and reasoning or status-message differences. Media takes a separate path. The pipeline can also use the assistant-message index to flush buffered content when crossing a logical message boundary. Otherwise, a progress note and the final proposal might be combined inappropriately. Block-reply pipeline

This explains why the model can have produced text while no new bubble has appeared yet. Content might be waiting for a readable breakpoint or for a coalescing window. To locate the delay, inspect each buffer rather than looking only at model output speed.

After sending two pieces, should it send the whole answer again?

Suppose the explanation arrives first, followed by the code example. At the end of the run, the surrounding system obtains the complete answer. Sending that answer unconditionally would deliver both an installment edition and a full-text edition.

OpenClaw's block pipeline tracks pending and sent payload keys. A Promise chain serializes send callbacks. Successful content and media URLs are recorded only after the callback succeeds. Final-reply assembly consults those records to filter content already delivered. Send bookkeeping, Final-reply filtering

Duplication itself has several forms. The pipeline has a delivery key that considers the reply target and a content key used for final-content suppression. The latter omits replyToId, preventing identical content from reappearing merely because the final payload carries a different reply-target field. For plain text already delivered as pieces, it can also compare the joined fragments against the final text while ignoring whitespace differences.

This is a specific implementation strategy, not an exactly-once guarantee across platforms or process restarts. Media, status messages, and ordinary text have different rules. New useful content cannot be discarded just because a preview appeared earlier. In particular, previews and ordinary block delivery deduplicate different objects; one layer's tracking set cannot explain every duplicate-message report.

Telegram's adapter also selects behavior based on preview and block-reply configuration to avoid double streaming. Whether finalization edits an existing preview or uses ordinary final delivery belongs to the channel's responsibilities. Telegram dispatch entry

What does “complete” mean if the last piece fails?

Streaming divides success into stages, and failure follows those stages too. If two pieces were sent but the last timed out, “there was no response” is inaccurate. So is “the complete answer was delivered.”

On timeout, the generic block pipeline signals cancellation, marks itself aborted, and skips remaining blocks to protect ordering. Ordinary send failures are logged and handled. didStream and isAborted describe different aspects; neither alone establishes complete delivery. Pipeline error handling

A cancellation signal cannot reverse time. If the remote platform received a message but its acknowledgement was lost, a local timeout does not prove the user saw nothing. This echoes the previous article's distinction between a timeout and the disappearance of underlying work. No real Telegram network failure was induced here, so the article does not claim a particular fault necessarily produces duplication or loss.

This investigation ran 24 local checks using the original chunker and fence parser, plus an unchanged extraction of the text-fragment helper. They cover buffering, final flushing, paragraph boundaries, long text, code fences, and repeated complete-text events. Coalescing, send orchestration, and Telegram adaptation were examined in source; there were no live channel calls, model calls, or full end-to-end tests.

If implementing this myself, I would record “what the model produced,” “what should be displayed,” and “what received a send acknowledgement” separately from the first version. Chunking, pacing, and channel transport would have separate interfaces, keeping a channel's edit rules out of the model loop. Diagnostics would preserve stages and message identifiers so that seeing some text and receiving a complete answer remain distinguishable.

Return to the checklist with its code example. What appears on screen is not simply model text crossing a network. It is the result of normalization, buffering, chunking, and delivery. Next, we will look in the other direction: when the conversation grows beyond the model's context capacity, which parts does OpenClaw keep, and which become a summary?

继续阅读