Skip to content

Commit 6441e56

Browse files
snowzlmbotobviyus
andauthored
fix(telegram): materialize streaming progress placeholders (#95183)
* fix(telegram): materialize streaming progress placeholders * fix(telegram): cancel delayed progress drafts before final * fix(telegram): satisfy progress placeholder lint * fix(telegram): cancel delayed draft preview on clear * refactor(telegram): simplify delayed preview flush --------- Co-authored-by: snowzlmbot <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 6daabd2 commit 6441e56

8 files changed

Lines changed: 202 additions & 7 deletions

File tree

docs/channels/telegram.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
336336
Requirement:
337337

338338
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
339-
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
339+
- short initial answer previews are debounced, then materialized after a bounded delay if the run is still active
340+
- `progress` keeps one editable status draft for tool progress, shows the stable status label when answer activity arrives before tool progress, clears it at completion, and sends the final answer as a normal message
340341
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
341342
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
342343
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft

docs/concepts/streaming.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,10 @@ Legacy key migration:
160160
Telegram:
161161

162162
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
163+
- Short initial previews are still debounced for push-notification UX, but Telegram now materializes them after a bounded delay so active runs do not stay visually silent.
163164
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
164165
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
165-
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
166+
- `progress` mode keeps tool progress in an editable status draft, materializes the status label when answer streaming is active but no tool line is available yet, clears that draft at completion, and sends the final answer through normal delivery.
166167
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
167168
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
168169
- `/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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
637637
chatId: 123,
638638
thread: { id: 777, scope: "dm" },
639639
minInitialChars: 30,
640+
minInitialDelayMs: 5000,
640641
});
641642
expect(draftStream.update).toHaveBeenCalledWith("Hello");
642643
const delivery = expectDeliverRepliesParams({ thread: { id: 777, scope: "dm" } });
@@ -2600,6 +2601,27 @@ describe("dispatchTelegramMessage draft streaming", () => {
26002601
expect(editMessageTelegram).not.toHaveBeenCalled();
26012602
});
26022603

2604+
it("shows a stable progress placeholder for progress-mode answer activity", async () => {
2605+
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
2606+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
2607+
await replyOptions?.onPartialReply?.({ text: "Short" });
2608+
await replyOptions?.onPartialReply?.({ text: "Short answer" });
2609+
return { queuedFinal: false };
2610+
});
2611+
2612+
await dispatchWithContext({
2613+
context: createContext(),
2614+
streamMode: "progress",
2615+
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
2616+
});
2617+
2618+
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Short");
2619+
expect(answerDraftStream.update).not.toHaveBeenCalledWith("Short answer");
2620+
expect(answerDraftStream.updatePreview).toHaveBeenCalledWith(
2621+
telegramHtmlPreview("<b>Shelling</b>"),
2622+
);
2623+
});
2624+
26032625
it("replaces Telegram command progress items with matching command output", async () => {
26042626
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
26052627
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-d
152152

153153
/** Minimum chars before sending first streaming message (improves push notification UX) */
154154
const DRAFT_MIN_INITIAL_CHARS = 30;
155+
const DRAFT_MIN_INITIAL_DELAY_MS = 5_000;
155156

156157
type DraftPartialTextUpdate = {
157158
text: string;
@@ -1005,6 +1006,7 @@ export const dispatchTelegramMessage = async ({
10051006
replyToMessageId: draftReplyToMessageId,
10061007
richMessages: telegramCfg.richMessages,
10071008
minInitialChars: draftMinInitialChars,
1009+
minInitialDelayMs: draftMinInitialChars > 0 ? DRAFT_MIN_INITIAL_DELAY_MS : undefined,
10081010
renderText: renderStreamText,
10091011
onSupersededPreview: (superseded) => {
10101012
if (superseded.retain) {
@@ -1364,7 +1366,7 @@ export const dispatchTelegramMessage = async ({
13641366
recomputeQueuedAnswerBlockRotations();
13651367
}
13661368
};
1367-
const updateDraftFromPartial = (lane: DraftLaneState, update: DraftPartialTextUpdate) => {
1369+
const updateDraftFromPartial = async (lane: DraftLaneState, update: DraftPartialTextUpdate) => {
13681370
const laneStream = lane.stream;
13691371
if (!laneStream || !update.text) {
13701372
return;
@@ -1376,6 +1378,7 @@ export const dispatchTelegramMessage = async ({
13761378
}
13771379
if (lane === answerLane) {
13781380
if (streamMode === "progress") {
1381+
await progressDraft.noteActivity();
13791382
return;
13801383
}
13811384
resetAnswerToolProgressDraft();
@@ -1402,7 +1405,7 @@ export const dispatchTelegramMessage = async ({
14021405
reasoningStepState.noteReasoningHint();
14031406
reasoningStepState.noteReasoningDelivered();
14041407
}
1405-
updateDraftFromPartial(lanes[segment.lane], segment.update);
1408+
await updateDraftFromPartial(lanes[segment.lane], segment.update);
14061409
}
14071410
};
14081411
const flushDraftLane = async (lane: DraftLaneState) => {

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,11 +843,16 @@ describe("createTelegramDraftStream", () => {
843843
describe("draft stream initial message debounce", () => {
844844
const createMockApi = () => createMockDraftApi(async () => ({ message_id: 42 }));
845845

846-
function createDebouncedStream(api: ReturnType<typeof createMockApi>, minInitialChars = 30) {
846+
function createDebouncedStream(
847+
api: ReturnType<typeof createMockApi>,
848+
minInitialChars = 30,
849+
minInitialDelayMs?: number,
850+
) {
847851
return createTelegramDraftStream({
848852
api: api as unknown as Bot["api"],
849853
chatId: 123,
850854
minInitialChars,
855+
minInitialDelayMs,
851856
});
852857
}
853858

@@ -916,6 +921,36 @@ describe("draft stream initial message debounce", () => {
916921
expect(api.sendMessage).toHaveBeenCalled();
917922
});
918923

924+
it("materializes a short first message after the initial delay", async () => {
925+
const api = createMockApi();
926+
const stream = createDebouncedStream(api, 30, 5000);
927+
928+
stream.update("Processing");
929+
await stream.flush();
930+
expect(api.sendMessage).not.toHaveBeenCalled();
931+
932+
await vi.advanceTimersByTimeAsync(5000);
933+
934+
expectPreviewSend(api, "Processing");
935+
});
936+
937+
it("cancels a delayed first message when clear() removes the draft", async () => {
938+
const api = createMockApi();
939+
const stream = createDebouncedStream(api, 30, 5000);
940+
941+
stream.update("Processing");
942+
await stream.flush();
943+
expect(api.sendMessage).not.toHaveBeenCalled();
944+
expect(vi.getTimerCount()).toBe(1);
945+
946+
await stream.clear();
947+
expect(vi.getTimerCount()).toBe(0);
948+
949+
await vi.advanceTimersByTimeAsync(5000);
950+
expect(api.sendMessage).not.toHaveBeenCalled();
951+
expect(api.editMessageText).not.toHaveBeenCalled();
952+
});
953+
919954
it("works with longer text above threshold", async () => {
920955
const api = createMockApi();
921956
const stream = createDebouncedStream(api);

extensions/telegram/src/draft-stream.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ export function createTelegramDraftStream(params: {
178178
throttleMs?: number;
179179
/** Minimum chars before sending first message (debounce for push notifications) */
180180
minInitialChars?: number;
181+
/** Maximum time to hold a short first preview before materializing it anyway. */
182+
minInitialDelayMs?: number;
181183
/** Optional preview renderer (e.g. markdown -> HTML + parse mode). */
182184
renderText?: (text: string) => TelegramDraftPreview;
183185
/** Called when a late send resolves after forceNewMessage() switched generations. */
@@ -190,6 +192,7 @@ export function createTelegramDraftStream(params: {
190192
const maxChars = Math.min(params.maxChars ?? transportLimit, transportLimit);
191193
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
192194
const minInitialChars = params.minInitialChars;
195+
const minInitialDelayMs = params.minInitialDelayMs;
193196
const chatId = params.chatId;
194197
const threadParams = buildTelegramThreadParams(params.thread);
195198
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
@@ -224,6 +227,8 @@ export function createTelegramDraftStream(params: {
224227
let lastDeliveredText = "";
225228
let lastRequestedText = "";
226229
let lastRequestedPreview: TelegramDraftPreview | undefined;
230+
let firstShortPreviewSeenMs: number | undefined;
231+
let initialPreviewTimer: ReturnType<typeof setTimeout> | undefined;
227232
let previewRevision = 0;
228233
let generation = 0;
229234
let deliveredTextOffset = 0;
@@ -319,6 +324,26 @@ export function createTelegramDraftStream(params: {
319324
streamVisibleSinceMs = visibleSinceMs;
320325
return true;
321326
};
327+
const clearInitialPreviewTimer = () => {
328+
if (initialPreviewTimer) {
329+
clearTimeout(initialPreviewTimer);
330+
initialPreviewTimer = undefined;
331+
}
332+
};
333+
const scheduleInitialPreviewFlush = (delayMs: number) => {
334+
if (initialPreviewTimer) {
335+
return;
336+
}
337+
initialPreviewTimer = setTimeout(
338+
() => {
339+
initialPreviewTimer = undefined;
340+
void flushInitialPreview().catch((err: unknown) => {
341+
params.warn?.(`telegram stream preview delayed send failed: ${formatErrorMessage(err)}`);
342+
});
343+
},
344+
Math.max(0, delayMs),
345+
);
346+
};
322347
const stopOversizedPreview = (payloadLength: number): false => {
323348
streamState.stopped = true;
324349
params.warn?.(`telegram stream preview stopped (text length ${payloadLength} > ${maxChars})`);
@@ -405,8 +430,24 @@ export function createTelegramDraftStream(params: {
405430

406431
if (typeof streamMessageId !== "number" && minInitialChars != null && !streamState.final) {
407432
if (renderedText.length < minInitialChars) {
408-
return false;
433+
if (minInitialDelayMs == null) {
434+
return false;
435+
}
436+
const now = Date.now();
437+
firstShortPreviewSeenMs ??= now;
438+
const remainingDelayMs = minInitialDelayMs - (now - firstShortPreviewSeenMs);
439+
if (remainingDelayMs > 0) {
440+
scheduleInitialPreviewFlush(remainingDelayMs);
441+
return false;
442+
}
443+
clearInitialPreviewTimer();
444+
} else {
445+
firstShortPreviewSeenMs = undefined;
446+
clearInitialPreviewTimer();
409447
}
448+
} else {
449+
firstShortPreviewSeenMs = undefined;
450+
clearInitialPreviewTimer();
410451
}
411452

412453
const previousSentPreviewKey = lastSentPreviewKey;
@@ -467,6 +508,7 @@ export function createTelegramDraftStream(params: {
467508
state: streamState,
468509
sendOrEditStreamMessage,
469510
});
511+
const flushInitialPreview = loop.flush;
470512

471513
const requestDraftUpdate = (text: string, preview?: TelegramDraftPreview) => {
472514
if (streamState.stopped || streamState.final) {
@@ -513,6 +555,8 @@ export function createTelegramDraftStream(params: {
513555
messageSendAttempted = false;
514556
streamMessageId = undefined;
515557
streamVisibleSinceMs = undefined;
558+
firstShortPreviewSeenMs = undefined;
559+
clearInitialPreviewTimer();
516560
lastSentPreviewKey = "";
517561
if (options?.resetOffset !== false) {
518562
deliveredTextOffset = 0;
@@ -526,6 +570,7 @@ export function createTelegramDraftStream(params: {
526570
};
527571

528572
const clear = async () => {
573+
clearInitialPreviewTimer();
529574
const messageId = await takeMessageIdAfterStop({
530575
stopForClear,
531576
readMessageId: () => streamMessageId,
@@ -544,6 +589,7 @@ export function createTelegramDraftStream(params: {
544589
};
545590

546591
const discard = async () => {
592+
clearInitialPreviewTimer();
547593
await stopForClear();
548594
};
549595

src/channels/progress-draft-compositor.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,70 @@ describe("createChannelProgressDraftCompositor", () => {
2121
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
2222
});
2323

24+
it("materializes a label-only progress draft after upstream answer activity", async () => {
25+
vi.useFakeTimers();
26+
try {
27+
const update = vi.fn();
28+
const progress = createChannelProgressDraftCompositor({
29+
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
30+
mode: "progress",
31+
active: true,
32+
seed: "test",
33+
update,
34+
});
35+
36+
await progress.noteActivity();
37+
expect(update).not.toHaveBeenCalled();
38+
39+
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
40+
41+
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
42+
} finally {
43+
vi.useRealTimers();
44+
}
45+
});
46+
47+
it("does not materialize delayed answer activity after final delivery starts or lands", async () => {
48+
vi.useFakeTimers();
49+
try {
50+
for (const markFinal of ["markFinalReplyStarted", "markFinalReplyDelivered"] as const) {
51+
const update = vi.fn();
52+
const progress = createChannelProgressDraftCompositor({
53+
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
54+
mode: "progress",
55+
active: true,
56+
seed: "test",
57+
update,
58+
});
59+
60+
await progress.noteActivity();
61+
progress[markFinal]();
62+
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
63+
64+
expect(update).not.toHaveBeenCalled();
65+
expect(progress.hasStarted).toBe(false);
66+
}
67+
} finally {
68+
vi.useRealTimers();
69+
}
70+
});
71+
72+
it("starts a label-only progress draft on repeated upstream answer activity", async () => {
73+
const update = vi.fn();
74+
const progress = createChannelProgressDraftCompositor({
75+
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
76+
mode: "progress",
77+
active: true,
78+
seed: "test",
79+
update,
80+
});
81+
82+
await progress.noteActivity();
83+
await progress.noteActivity();
84+
85+
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
86+
});
87+
2488
it("passes structured progress lines to draft updates", async () => {
2589
const update = vi.fn();
2690
const progress = createChannelProgressDraftCompositor({

src/channels/progress-draft-compositor.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function createChannelProgressDraftCompositor(params: {
8181
};
8282

8383
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
84-
if (!params.active || params.mode !== "progress") {
84+
if (!params.active || params.mode !== "progress" || finalReplyStarted || finalReplyDelivered) {
8585
return false;
8686
}
8787
const text = formatDraftText();
@@ -197,9 +197,11 @@ export function createChannelProgressDraftCompositor(params: {
197197
},
198198
markFinalReplyStarted() {
199199
finalReplyStarted = true;
200+
gate.cancel();
200201
},
201202
markFinalReplyDelivered() {
202203
finalReplyDelivered = true;
204+
gate.cancel();
203205
},
204206
reset() {
205207
clearProgressState(false);
@@ -216,6 +218,27 @@ export function createChannelProgressDraftCompositor(params: {
216218
start() {
217219
return gate.startNow();
218220
},
221+
async noteActivity(options?: { startImmediately?: boolean }) {
222+
if (
223+
!params.active ||
224+
params.mode !== "progress" ||
225+
progressSuppressed ||
226+
finalReplyStarted ||
227+
finalReplyDelivered
228+
) {
229+
return false;
230+
}
231+
if (options?.startImmediately) {
232+
await gate.startNow();
233+
return gate.hasStarted ? await render({ flush: true }) : false;
234+
}
235+
const alreadyStarted = gate.hasStarted;
236+
const progressActive = await gate.noteWork();
237+
if ((alreadyStarted || progressActive) && gate.hasStarted) {
238+
return await render();
239+
}
240+
return false;
241+
},
219242
pushToolProgress: noteProgress,
220243
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
221244
if (

0 commit comments

Comments
 (0)