fix(telegram): coalesce streaming preview across tool-call rounds in partial mode#39213
fix(telegram): coalesce streaming preview across tool-call rounds in partial mode#39213chalawbot wants to merge 2 commits intoopenclaw:mainfrom
Conversation
…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>
Greptile SummaryThis PR fixes a UX regression in Telegram's partial streaming mode where intermediate text blocks between sequential tool calls (e.g. Changes:
One minor observation: The preemptive early-partial branch in Confidence Score: 4/5
Last reviewed commit: 7c9a44f |
| 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; | ||
| } |
There was a problem hiding this 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:
answerLane.stream?.revive();
finalizedPreviewByLane.answer = false;
// ← resetDraftLaneState is absent hereIn 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:
| 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.There was a problem hiding this comment.
💡 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".
| // Partial mode: reuse the same preview — just revive the stream. | ||
| answerLane.stream?.revive(); | ||
| finalizedPreviewByLane.answer = false; |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
💡 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".
| answerLane.stream?.revive(); | ||
| resetDraftLaneState(answerLane); |
There was a problem hiding this comment.
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 👍 / 👎.
|
Maintainer triage call: hold this PR for now (keep open). Reason:
Requested next step:
|
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_search→web_fetch→web_search_pro, each intermediate narration ("Let me search...", "Trying another approach...") became a permanent separate message visible to the user.Root cause:
onAssistantMessageStartunconditionally calledrotateAnswerLaneForNewAssistantMessage(), which materialized the current preview and forced a new message viaforceNewMessage()at every tool-call boundary — even in partial mode where the user expects a single updating preview.Fix: Add
shouldSplitPreviewMessagesflag (only true inblockmode), mirroring Discord's existing pattern. In partial mode:revive()re-opens the stream lifecycle without clearing the message IDresetDraftLaneState()clears text state for the next roundKey changes
draft-stream.ts— Newrevive()method: resetsfinalstate without clearingmessageId, allowing continued editing afterstop(). Refuses to resurrect error-stopped streams.bot-message-dispatch.ts— Gate rotation behindshouldSplitPreviewMessagesin bothonAssistantMessageStartand the preemptive rotation iningestDraftLaneSegments.streamMode: "block". New test verifies partial mode coalesces across tool-call rounds. Two newrevive()unit tests (happy path + error-stopped guard).Linked Issue
Fixes #32535
User-visible Changes
Test plan
bot-message-dispatch.test.tstests passdraft-stream.test.tstests pass (including 2 newrevivetests)lane-delivery.test.tstests passsrc/telegram/test suite: 732 pass, 1 pre-existing unrelated failuretsc --noEmit)pnpm build)🤖 Generated with Claude Code