Skip to content

fix(telegram): coalesce streaming preview across tool-call rounds in partial mode#39213

Open
chalawbot wants to merge 2 commits intoopenclaw:mainfrom
chalawbot:fix/32535-coalesce-partial-streaming
Open

fix(telegram): coalesce streaming preview across tool-call rounds in partial mode#39213
chalawbot wants to merge 2 commits intoopenclaw:mainfrom
chalawbot:fix/32535-coalesce-partial-streaming

Conversation

@chalawbot
Copy link

Summary

In partial streaming mode, intermediate text blocks between tool calls were sent as separate Telegram messages instead of editing a single message in place. For example, when a model runs sequential tool calls like web_searchweb_fetchweb_search_pro, each intermediate narration ("Let me search...", "Trying another approach...") became a permanent separate message visible to the user.

Root cause: onAssistantMessageStart unconditionally called rotateAnswerLaneForNewAssistantMessage(), which materialized the current preview and forced a new message via forceNewMessage() at every tool-call boundary — even in partial mode where the user expects a single updating preview.

Fix: Add shouldSplitPreviewMessages flag (only true in block mode), mirroring Discord's existing pattern. In partial mode:

  • revive() re-opens the stream lifecycle without clearing the message ID
  • resetDraftLaneState() clears text state for the next round
  • Subsequent partials continue editing the same preview message

Key changes

  1. draft-stream.ts — New revive() method: resets final state without clearing messageId, allowing continued editing after stop(). Refuses to resurrect error-stopped streams.
  2. bot-message-dispatch.ts — Gate rotation behind shouldSplitPreviewMessages in both onAssistantMessageStart and the preemptive rotation in ingestDraftLaneSegments.
  3. Tests — Rotation tests now explicitly use streamMode: "block". New test verifies partial mode coalesces across tool-call rounds. Two new revive() unit tests (happy path + error-stopped guard).

Linked Issue

Fixes #32535

User-visible Changes

  • In partial streaming mode, multi-tool-call turns now show one progressively-edited preview message instead of separate message fragments per tool-call round. Only the final response text persists.
  • Block mode behavior is unchanged.

Test plan

  • All 87 bot-message-dispatch.test.ts tests pass
  • All 29 draft-stream.test.ts tests pass (including 2 new revive tests)
  • All 13 lane-delivery.test.ts tests pass
  • Full src/telegram/ test suite: 732 pass, 1 pre-existing unrelated failure
  • Type check clean (tsc --noEmit)
  • Build clean (pnpm build)

🤖 Generated with Claude Code

…partial mode

In partial streaming mode, intermediate text blocks between tool calls
(e.g. "Let me search...", "Trying another approach...") were each sent
as separate Telegram messages. This happened because
`onAssistantMessageStart` unconditionally called
`rotateAnswerLaneForNewAssistantMessage()`, which materialized the
current preview and forced a new message for each tool-call round.

Add `shouldSplitPreviewMessages` flag (true only in block mode) to
align with Discord's existing pattern. In partial mode, tool-call
rounds now share a single preview message that is edited in place.
Only the final response text persists.

Changes:
- Add `revive()` method to `TelegramDraftStream` that re-opens the
  stream lifecycle (resets `final` state) without clearing the message
  ID, allowing continued editing of the same message after `stop()`.
  Error-stopped streams are not resurrected.
- Gate `rotateAnswerLaneForNewAssistantMessage()` behind
  `shouldSplitPreviewMessages` in both `onAssistantMessageStart` and
  the preemptive rotation in `ingestDraftLaneSegments`.
- In partial mode, call `revive()` + `resetDraftLaneState()` instead
  of rotating, so subsequent partials edit the same preview message.

Fixes openclaw#32535

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@openclaw-barnacle openclaw-barnacle bot added channel: telegram Channel integration: telegram size: S labels Mar 7, 2026
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes a UX regression in Telegram's partial streaming mode where intermediate text blocks between sequential tool calls (e.g. web_search → web_fetch → web_search_pro) produced separate permanent messages instead of editing a single preview in place. The root cause was onAssistantMessageStart unconditionally calling rotateAnswerLaneForNewAssistantMessage() (which materialises + force-creates a new message) even in partial mode.

Changes:

  • draft-stream.ts — New revive() method resets streamState.final and throttle state while preserving streamMessageId, so subsequent update() calls continue editing the same Telegram message. Error-stopped streams are correctly guarded against resurrection.
  • bot-message-dispatch.ts — Introduces shouldSplitPreviewMessages (only true in "block" mode), mirroring the existing Discord pattern. Both the early-partial preemptive path and onAssistantMessageStart now branch on this flag: block mode rotates as before; partial mode calls revive() to reuse the existing preview.
  • Tests — Existing rotation tests correctly re-scoped to streamMode: "block"; new tests verify partial-mode coalescing at both the stream unit level and the dispatch integration level.

One minor observation: The preemptive early-partial branch in ingestDraftLaneSegments (partial mode) calls revive() but omits resetDraftLaneState(answerLane), creating a small asymmetry with the onAssistantMessageStart handler. Because onAssistantMessageStart always follows and resets lane state, this is not a bug in the current provider contract, but it could silently suppress a partial in an edge case where onAssistantMessageStart is never emitted (see inline comment).

Confidence Score: 4/5

  • Safe to merge — fix is well-scoped, block mode is unchanged, and new behaviour is well-covered by tests.
  • The logic is correct and the change precisely mirrors the established Discord pattern. revive() is minimal and its invariants (preserves streamMessageId, guards error-stopped streams) are verified by dedicated unit tests. The only deduction is the minor asymmetry in ingestDraftLaneSegments where resetDraftLaneState is omitted in the partial-mode preemptive path, which could cause a silent dedup miss if a provider violates the onAssistantMessageStart contract — low probability but worth a one-line fix.
  • The preemptive partial-mode branch in src/telegram/bot-message-dispatch.ts (ingestDraftLaneSegments, around line 339–347) deserves a second look for the missing resetDraftLaneState call.

Last reviewed commit: 7c9a44f

Comment on lines +339 to +347
if (shouldSplitPreviewMessages) {
// Block mode: rotate preemptively so we do not edit the previously
// finalized preview message with the next message's text.
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
} else {
// Partial mode: reuse the same preview — just revive the stream.
answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preemptive partial path skips resetDraftLaneState in partial mode

In ingestDraftLaneSegments, the partial-mode preemptive branch calls revive() but does not call resetDraftLaneState(answerLane), while the onAssistantMessageStart handler (which always follows) does both:

answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;
// ← resetDraftLaneState is absent here

In practice onAssistantMessageStart is always emitted after the first early partial, so lastPartialText and hasStreamedMessage are reset there. However, if a provider that emits early partials never sends onAssistantMessageStart, the stale lastPartialText from the previous round will suppress the first identical partial of the new round (silently dropped by the dedup check in updateDraftFromPartial). For robustness, consider adding resetDraftLaneState(answerLane) here to mirror the onAssistantMessageStart path:

Suggested change
if (shouldSplitPreviewMessages) {
// Block mode: rotate preemptively so we do not edit the previously
// finalized preview message with the next message's text.
skipNextAnswerMessageStartRotation = await rotateAnswerLaneForNewAssistantMessage();
} else {
// Partial mode: reuse the same preview — just revive the stream.
answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;
}
// Partial mode: reuse the same preview — just revive the stream.
answerLane.stream?.revive();
resetDraftLaneState(answerLane);
finalizedPreviewByLane.answer = false;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/telegram/bot-message-dispatch.ts
Line: 339-347

Comment:
**Preemptive partial path skips `resetDraftLaneState` in partial mode**

In `ingestDraftLaneSegments`, the partial-mode preemptive branch calls `revive()` but does not call `resetDraftLaneState(answerLane)`, while the `onAssistantMessageStart` handler (which always follows) does both:

```ts
answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;
// ← resetDraftLaneState is absent here
```

In practice `onAssistantMessageStart` is always emitted after the first early partial, so `lastPartialText` and `hasStreamedMessage` are reset there. However, if a provider that emits early partials *never* sends `onAssistantMessageStart`, the stale `lastPartialText` from the previous round will suppress the first identical partial of the new round (silently dropped by the dedup check in `updateDraftFromPartial`). For robustness, consider adding `resetDraftLaneState(answerLane)` here to mirror the `onAssistantMessageStart` path:

```suggestion
        // Partial mode: reuse the same preview — just revive the stream.
        answerLane.stream?.revive();
        resetDraftLaneState(answerLane);
        finalizedPreviewByLane.answer = false;
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c9a44fcc3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +344 to +346
// Partial mode: reuse the same preview — just revive the stream.
answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset lane snapshot in partial pre-rotation path

When a new round’s first answer chunk arrives before onAssistantMessageStart, this branch revives the stream but keeps the previous round’s lastPartialText/hasStreamedMessage state. In that ordering, updateDraftFromPartial can classify the new chunk as regressive (old.startsWith(new)), skip it, and keep showing stale preview text until a later non-prefix chunk/final arrives. The block-mode path already calls rotateAnswerLaneForNewAssistantMessage() (which resets lane state), so partial mode should also clear the lane snapshot here to avoid cross-round suppression.

Useful? React with 👍 / 👎.

…ial mode

After the first final edits the preview and sets finalizedPreviewByLane.answer,
subsequent finals fell through to sendPayload creating separate messages.
Now in partial mode, revive the stream and reset the finalized flag so all
finals re-edit the same preview message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df56d605ad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +684 to +685
answerLane.stream?.revive();
resetDraftLaneState(answerLane);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve finalized preview across media-only assistant rounds

In partial mode this branch switched from rotateAnswerLaneForNewAssistantMessage() to revive(), so the previous finalized answer preview is no longer archived before the next assistant round starts. If that next round emits no answer text (for example a media-only final), finalizedPreviewByLane.answer gets cleared later in the same handler, no new answer final re-finalizes it, and the finally cleanup path calls stream.clear() and deletes the already-delivered answer message. This is a regression from the prior rotation/materialize flow and will drop visible replies in mixed text→media multi-round turns.

Useful? React with 👍 / 👎.

@steipete
Copy link
Contributor

steipete commented Mar 8, 2026

Maintainer triage call: hold this PR for now (keep open).

Reason:

Requested next step:

  1. rebase onto latest main
  2. fold in fix(telegram): use message transport for all DM streaming lanes #38906 behavior explicitly
  3. address unresolved boundary regression threads
  4. rerun full gate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: telegram Channel integration: telegram size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inter-tool-call text blocks leak as separate messages (Telegram streaming preview)

2 participants