Skip to content

Commit c7fe3ec

Browse files
feat(telegram): persist progress in place with lossless overflow spill
Persisted progress keeps every accumulated tool/commentary line (no rolling maxLines drop) and, when the live draft outgrows a message, rolls the overflow into a fresh message via the existing supersede primitive (retain the filled message) instead of truncating. At turn end the standing progress message is finalized in place rather than deleted, and the final answer goes to a new message. Gated on streaming.progress.persistProgress.
1 parent 424db57 commit c7fe3ec

13 files changed

Lines changed: 220 additions & 24 deletions

docs/channels/telegram.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
320320
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
321321
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
322322
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
323+
- `streaming.progress.persistProgress` (default: `false`) **persists** the progress draft in place above the final answer instead of clearing it, and accumulates every tool/commentary line losslessly (spilling to a new message at the 4096-char limit rather than dropping the oldest line). Progress mode only
323324
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
324325

325326
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
@@ -405,7 +406,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
405406

406407
- short DM/group/topic previews: OpenClaw keeps the same preview message and performs the final edit in place
407408
- long text finals that split into multiple Telegram messages reuse the existing preview as the first final chunk when possible, then send only the remaining chunks
408-
- progress-mode finals clear the status draft and use normal final delivery instead of editing the draft into the answer
409+
- progress-mode finals clear the status draft and use normal final delivery instead of editing the draft into the answer (unless `streaming.progress.persistProgress` is set, in which case the draft is finalized in place above the answer rather than cleared)
409410
- if the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview
410411

411412
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.

docs/concepts/streaming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Telegram:
161161

162162
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
163163
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
164-
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
164+
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery. Set `streaming.progress.persistProgress: true` to instead **persist** the draft in place above the final answer (finalized, not cleared) and accumulate every tool/commentary line losslessly — spilling to a new message at the channel limit rather than dropping the oldest line. Default `false`.
165165
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
166166
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
167167
- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery.

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
597597
expect(draftStream.clear).toHaveBeenCalledTimes(1);
598598
});
599599

600+
it("keeps persistProgress inert in partial mode (no lossless answer-lane spill)", async () => {
601+
createTelegramDraftStream.mockReturnValue(createDraftStream());
602+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
603+
async ({ dispatcherOptions, replyOptions }) => {
604+
await replyOptions?.onPartialReply?.({ text: "Hi" });
605+
await dispatcherOptions.deliver({ text: "Hi" }, { kind: "final" });
606+
return { queuedFinal: true };
607+
},
608+
);
609+
deliverReplies.mockResolvedValue({ delivered: true });
610+
611+
await dispatchWithContext({
612+
context: createContext(),
613+
streamMode: "partial",
614+
telegramCfg: { streaming: { mode: "partial", progress: { persistProgress: true } } },
615+
});
616+
617+
// Live persistProgress is progress-mode only: the answer-lane draft must not
618+
// enable lossless spill in partial mode even when the flag is set.
619+
expectDraftStreamParams({ losslessSpill: false });
620+
});
621+
622+
it("enables lossless answer-lane spill only in progress mode with persistProgress", async () => {
623+
createTelegramDraftStream.mockReturnValue(createDraftStream());
624+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
625+
async ({ dispatcherOptions, replyOptions }) => {
626+
await replyOptions?.onPartialReply?.({ text: "Hi" });
627+
await dispatcherOptions.deliver({ text: "Hi" }, { kind: "final" });
628+
return { queuedFinal: true };
629+
},
630+
);
631+
deliverReplies.mockResolvedValue({ delivered: true });
632+
633+
await dispatchWithContext({
634+
context: createContext(),
635+
streamMode: "progress",
636+
telegramCfg: { streaming: { mode: "progress", progress: { persistProgress: true } } },
637+
});
638+
639+
expectDraftStreamParams({ losslessSpill: true });
640+
});
641+
600642
it("recovers forum thread context from a topic-scoped session key", async () => {
601643
const recordInboundSession = vi.fn(async () => undefined);
602644
const oldHistoryKey = "-1003774691294:topic:1";

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
resolveChannelStreamingPreviewNativeToolProgress,
3535
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
3636
resolveChannelStreamingPreviewToolProgress,
37+
resolveChannelStreamingProgressPersist,
3738
resolveTranscriptBackedChannelFinalText,
3839
} from "openclaw/plugin-sdk/channel-outbound";
3940
import type {
@@ -905,12 +906,22 @@ export const dispatchTelegramMessage = async ({
905906
const draftMinInitialChars = streamMode === "progress" ? 0 : DRAFT_MIN_INITIAL_CHARS;
906907
const progressSeed = `${route.accountId}:${chatId}:${threadSpec.id ?? ""}`;
907908
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
909+
const persistProgressEnabled = resolveChannelStreamingProgressPersist(telegramCfg);
908910
const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => {
909911
const stream = enabled
910912
? (telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({
911913
api: bot.api,
912914
chatId,
913915
maxChars: draftMaxChars,
916+
// Persisted progress must never drop accumulated lines: when the answer
917+
// lane's live draft outgrows a message, spill into a new one losslessly.
918+
// Progress mode only — in partial/block the compositor can still render
919+
// tool progress into this lane, so an unguarded flag would change live
920+
// preview overflow/retention for those modes. The persistProgressEnabled
921+
// const stays guard-free (the stream-off accumulator needs it); only this
922+
// live-draft usage is gated.
923+
losslessSpill:
924+
laneName === "answer" && streamMode === "progress" && persistProgressEnabled,
914925
thread: threadSpec,
915926
replyToMessageId: draftReplyToMessageId,
916927
minInitialChars: draftMinInitialChars,
@@ -1114,7 +1125,18 @@ export const dispatchTelegramMessage = async ({
11141125
if (!activeAnswerDraftIsToolProgressOnly) {
11151126
return false;
11161127
}
1117-
await answerLane.stream?.clear();
1128+
if (streamMode === "progress" && progressDraft.persistProgressEnabled) {
1129+
// Persist-in-place (progress mode only): finalize the standing progress
1130+
// message rather than deleting it, then route the final answer into a fresh
1131+
// message so the tool/commentary log remains visible above the answer. The
1132+
// streamMode guard keeps persistProgress inert in partial/block modes —
1133+
// matching the skip-rotate gate below — so the flag only ever changes the
1134+
// live progress-draft path (stream-off persistence is handled separately by
1135+
// the #89890 accumulator, which has no answer-lane stream to reach here).
1136+
await answerLane.stream?.stop();
1137+
} else {
1138+
await answerLane.stream?.clear();
1139+
}
11181140
answerLane.stream?.forceNewMessage();
11191141
resetDraftLaneState(answerLane);
11201142
suppressProgressDraftState();
@@ -1161,7 +1183,16 @@ export const dispatchTelegramMessage = async ({
11611183
) => {
11621184
const split = splitTextIntoLaneSegments(update, isReasoning);
11631185
for (const segment of split.segments) {
1164-
if (segment.lane === "answer") {
1186+
if (
1187+
segment.lane === "answer" &&
1188+
!(streamMode === "progress" && progressDraft.persistProgressEnabled)
1189+
) {
1190+
// Persisted progress accumulates the live draft in ONE message: an inter-tool
1191+
// assistant segment must not rotate the answer lane. In progress mode the answer
1192+
// partial isn't streamed here anyway (updateDraftFromPartial returns early) — the
1193+
// text renders as commentary in the draft. Without this skip, each inter-tool
1194+
// commentary finalizes the draft into a separate PERSISTED message. The final
1195+
// answer rotates the draft separately at delivery (deliverFinalAnswerText).
11651196
await prepareAnswerLaneForText();
11661197
}
11671198
if (segment.lane === "reasoning") {

extensions/telegram/src/config-schema.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ describe("telegram custom commands schema", () => {
123123
});
124124
});
125125

126+
it("accepts Telegram progress persistProgress config", () => {
127+
expectTelegramConfigValid({
128+
streaming: {
129+
mode: "progress",
130+
progress: { persistProgress: true },
131+
},
132+
accounts: {
133+
ops: {
134+
streaming: {
135+
progress: { persistProgress: true },
136+
},
137+
},
138+
},
139+
});
140+
});
141+
126142
it("rejects removed DM thread reply policy keys", () => {
127143
expectTelegramConfigIssue({ dm: { threadReplies: "off" } }, "");
128144
expectTelegramConfigIssue(

extensions/telegram/src/draft-stream.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,34 @@ describe("createTelegramDraftStream", () => {
446446
expect(onSupersededPreview).not.toHaveBeenCalled();
447447
});
448448

449+
it("spills non-final overflow into a new retained message when losslessSpill is set", async () => {
450+
const api = createMockDraftApi();
451+
api.sendMessage
452+
.mockResolvedValueOnce({ message_id: 17 })
453+
.mockResolvedValueOnce({ message_id: 42 });
454+
const onSupersededPreview = vi.fn();
455+
const stream = createDraftStream(api, {
456+
maxChars: 20,
457+
losslessSpill: true,
458+
onSupersededPreview,
459+
});
460+
461+
stream.update("Hello world");
462+
await stream.flush();
463+
stream.update("Hello world foo bar baz qux");
464+
await stream.flush();
465+
466+
// The filled message is kept and the overflow rolls into a fresh message
467+
// while still streaming — nothing is truncated (contrast the lossy default above).
468+
expect(api.sendMessage).toHaveBeenCalledTimes(2);
469+
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello world", undefined);
470+
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "foo bar baz qux", undefined);
471+
expect(onSupersededPreview).toHaveBeenCalledTimes(1);
472+
const [superseded] = onSupersededPreview.mock.calls.at(0) ?? [];
473+
expect(superseded.retain).toBe(true);
474+
expect(superseded.messageId).toBe(17);
475+
});
476+
449477
it("continues in a new message when a final rendered preview crosses maxChars", async () => {
450478
const api = createMockDraftApi();
451479
api.sendMessage

extensions/telegram/src/draft-stream.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ export function createTelegramDraftStream(params: {
8282
throttleMs?: number;
8383
/** Minimum chars before sending first message (debounce for push notifications) */
8484
minInitialChars?: number;
85+
/**
86+
* Lossless overflow handling for a still-streaming draft. When the live draft
87+
* grows past `maxChars`, retain the filled message and roll the overflow into a
88+
* fresh message (the same supersede-and-continue the final path uses) instead of
89+
* truncating/freezing the preview. Used by persistent progress so no progress is dropped.
90+
*/
91+
losslessSpill?: boolean;
8592
/** Optional preview renderer (e.g. markdown -> HTML + parse mode). */
8693
renderText?: (text: string) => TelegramDraftPreview;
8794
/** Called when a late send resolves after forceNewMessage() switched generations. */
@@ -95,6 +102,7 @@ export function createTelegramDraftStream(params: {
95102
);
96103
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
97104
const minInitialChars = params.minInitialChars;
105+
const losslessSpill = params.losslessSpill === true;
98106
const chatId = params.chatId;
99107
const threadParams = buildTelegramThreadParams(params.thread);
100108
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
@@ -215,6 +223,28 @@ export function createTelegramDraftStream(params: {
215223
if (renderedText.length > maxChars) {
216224
const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText);
217225
if (!streamState.final) {
226+
if (losslessSpill && lastDeliveredText.length > deliveredTextOffset) {
227+
// Lossless spill: the live draft outgrew maxChars. Retain the filled
228+
// message and roll the overflow into a fresh message (the same
229+
// supersede-and-continue the final path uses below) so accumulated
230+
// progress is never truncated or dropped.
231+
const supersededMessageId = streamMessageId;
232+
const supersededTextSnapshot = lastSentText;
233+
const supersededParseMode = lastSentParseMode;
234+
const supersededVisibleSinceMs = streamVisibleSinceMs;
235+
deliveredTextOffset = lastDeliveredText.length;
236+
resetStreamToNewMessage({ keepFinal: false, keepPending: true, resetOffset: false });
237+
if (typeof supersededMessageId === "number") {
238+
params.onSupersededPreview?.({
239+
messageId: supersededMessageId,
240+
textSnapshot: supersededTextSnapshot,
241+
parseMode: supersededParseMode,
242+
visibleSinceMs: supersededVisibleSinceMs,
243+
retain: true,
244+
});
245+
}
246+
return await sendOrEditStreamMessage(trimmed);
247+
}
218248
if (chunkLength > 0) {
219249
return await sendOrEditStreamMessage(
220250
trimmed.slice(0, deliveredTextOffset) + currentText.slice(0, chunkLength),

src/channels/progress-draft-compositor.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolveChannelProgressDraftMaxLineChars,
1515
resolveChannelProgressDraftMaxLines,
1616
resolveChannelStreamingProgressCommentary,
17+
resolveChannelStreamingProgressPersist,
1718
resolveChannelStreamingPreviewToolProgress,
1819
resolveChannelStreamingSuppressDefaultToolProgressMessages,
1920
type StreamingCompatEntry,
@@ -46,6 +47,8 @@ export function createChannelProgressDraftCompositor(params: {
4647
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
4748
const commentaryProgressEnabled =
4849
params.active && resolveChannelStreamingProgressCommentary(params.entry);
50+
const persistProgressEnabled =
51+
params.active && resolveChannelStreamingProgressPersist(params.entry);
4952
const suppressDefaultToolProgressMessages =
5053
params.active &&
5154
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
@@ -135,9 +138,14 @@ export function createChannelProgressDraftCompositor(params: {
135138
}
136139
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
137140
const shouldStoreLine = previewToolProgressEnabled;
141+
// Persisted progress must never drop the oldest line: keep every line and let
142+
// the draft stream spill into a new message once it outgrows the char limit
143+
// (lossless). Transient progress keeps the rolling maxLines cap.
138144
const nextLines = shouldStoreLine
139145
? mergeChannelProgressDraftLine(lines, progressLine, {
140-
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
146+
maxLines: persistProgressEnabled
147+
? Number.POSITIVE_INFINITY
148+
: resolveChannelProgressDraftMaxLines(params.entry),
141149
})
142150
: lines;
143151
if (shouldStoreLine && nextLines === lines) {
@@ -185,6 +193,9 @@ export function createChannelProgressDraftCompositor(params: {
185193
get commentaryProgressEnabled() {
186194
return commentaryProgressEnabled;
187195
},
196+
get persistProgressEnabled() {
197+
return persistProgressEnabled;
198+
},
188199
get suppressDefaultToolProgressMessages() {
189200
return suppressDefaultToolProgressMessages;
190201
},
@@ -284,7 +295,9 @@ export function createChannelProgressDraftCompositor(params: {
284295
noCompact: true,
285296
};
286297
lines = mergeChannelProgressDraftLine(lines, line, {
287-
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
298+
maxLines: persistProgressEnabled
299+
? Number.POSITIVE_INFINITY
300+
: resolveChannelProgressDraftMaxLines(params.entry),
288301
});
289302
await gate.startNow();
290303
return await render();

src/channels/streaming.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,19 @@ export function resolveChannelStreamingProgressCommentary(
729729
return asBoolean(progress?.commentary) ?? defaultValue;
730730
}
731731

732+
export function resolveChannelStreamingProgressPersist(
733+
entry: StreamingCompatEntry | null | undefined,
734+
defaultValue = false,
735+
): boolean {
736+
const config = getChannelStreamingConfigObject(entry);
737+
// No stream-mode guard: persistProgress is a user intent that applies in BOTH
738+
// stream-on (persist the live draft in place) and stream-off (#89890 emits the
739+
// accumulated set). Call sites that only make sense while streaming are already
740+
// gated by the presence of an active draft/compositor.
741+
const progress = asObjectRecord(config?.progress);
742+
return asBoolean(progress?.persistProgress) ?? defaultValue;
743+
}
744+
732745
export function resolveChannelStreamingPreviewCommandText(
733746
entry: StreamingCompatEntry | null | undefined,
734747
defaultValue: ChannelStreamingCommandTextMode = "raw",
@@ -1057,6 +1070,9 @@ export function formatChannelProgressDraftText(params: {
10571070
const resolvedLabel = rawLabel;
10581071
const maxLines = resolveChannelProgressDraftMaxLines(params.entry);
10591072
const maxLineChars = resolveChannelProgressDraftMaxLineChars(params.entry);
1073+
// Persisted progress keeps EVERY line (no rolling maxLines drop); it spills to a new
1074+
// message at the channel char limit rather than dropping the oldest lines.
1075+
const persist = resolveChannelStreamingProgressPersist(params.entry);
10601076
const formatLine = params.formatLine ?? ((line: string) => line);
10611077
const bullet = params.bullet ?? "•";
10621078
const rawLines: Array<string | ChannelProgressDraftLine | { draftLabel: string }> = resolvedLabel
@@ -1080,7 +1096,7 @@ export function formatChannelProgressDraftText(params: {
10801096
.filter((line): line is { text: string; isLabelLine: boolean; prefix: boolean } =>
10811097
Boolean(line),
10821098
)
1083-
.slice(-maxLines)
1099+
.slice(persist ? 0 : -maxLines)
10841100
.map(({ text, isLabelLine, prefix }) => {
10851101
const formatted = isLabelLine ? text : formatLine(text);
10861102
return {

src/config/bundled-channel-config-metadata.generated.ts

Lines changed: 17 additions & 17 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)