Skip to content

Commit 3932f4a

Browse files
Peetiegonzalezclaude
authored andcommitted
fix(telegram): reposition streaming window post-first, delete-old-deferred
Peter isolated the on-off focus-jump precisely: when a durable 🧠 posts BELOW the streaming window, the window (now above the reasoning) is repositioned to stay newest by DELETING it and reposting below. Telegram cannot move messages, so the delete-then-repost order scroll-jumps the client. Root cause: rotateAnswerLaneAfterToolProgress rewound the tool-progress window with stream.clear(), which deletes the old message IMMEDIATELY when it has been on screen past the dwell — before the replacement message is sent. Delete-first, post-second. Fix — invert the order, preserving arrival order: - draft-stream: new rotateToNewMessageDeferringDelete() rewinds the stream so the NEXT update creates a fresh message, and schedules the superseded message's delete for AFTER it (detached, floored at 1.5s so the new message lands first). Extracted the shared deferred-delete scheduler (scheduleDetachedDelete) used by both clear() and the reposition. (draft-stream.ts) - dispatch: rotateAnswerLaneAfterToolProgress now repositions via that method instead of clear()+forceNewMessage, so no window reposition deletes before the replacement lands. (bot-message-dispatch.ts:1259) Tests: draft-stream unit tests prove the sequencing (new message sent before the old is deleted; delete deferred; no-op with no live message). Added a dispatch repro (durable 🧠 then answer text mid-turn -> reposition, no clear). Updated the predating tool-progress-rotation tests to assert the deferred-delete reposition and that any deliverer-cleanup clear() runs only AFTER the rewind. 228 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 7933c8c commit 3932f4a

5 files changed

Lines changed: 228 additions & 43 deletions

File tree

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

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2746,8 +2746,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
27462746
expect.objectContaining({ text: expect.stringMatching(/🛠 Exec<\/b>$/) }),
27472747
);
27482748
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
2749-
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
2750-
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
2749+
// The tool-progress window repositions before the final (deferred delete),
2750+
// never an immediate clear/delete.
2751+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
2752+
// The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2753+
// so that clear finds no live message id and never deletes the window.
2754+
if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) {
2755+
expect(
2756+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0],
2757+
).toBeLessThan(answerDraftStream.clear.mock.invocationCallOrder[0]);
2758+
}
27512759
const progressResetOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
27522760
const progressUpdateOrder = answerDraftStream.updatePreview.mock.invocationCallOrder[0];
27532761
expect(progressResetOrder).toBeLessThan(progressUpdateOrder);
@@ -2774,8 +2782,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
27742782
);
27752783
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Site B shows Y.");
27762784
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, "Final answer");
2777-
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
2778-
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
2785+
// The tool-progress window repositions (deferred delete) rather than an
2786+
// immediate clear when the following text block takes over the lane.
2787+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
2788+
// The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2789+
// so that clear finds no live message id and never deletes the window.
2790+
if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) {
2791+
expect(
2792+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0],
2793+
).toBeLessThan(answerDraftStream.clear.mock.invocationCallOrder[0]);
2794+
}
27792795
expect(deliverReplies).not.toHaveBeenCalled();
27802796
});
27812797

@@ -2815,12 +2831,20 @@ describe("dispatchTelegramMessage draft streaming", () => {
28152831
expect.objectContaining({ text: expect.stringMatching(/🛠 Exec<\/b>$/) }),
28162832
);
28172833
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
2818-
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
2819-
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
2820-
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
2821-
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
2834+
// Reposition, not delete-then-repost: the tool-progress window is rewound
2835+
// for a new message and its delete deferred until after the replacement
2836+
// lands. clear() (immediate delete) must NOT run — that scroll-jumps.
2837+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
2838+
// The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2839+
// so that clear finds no live message id and never deletes the window.
2840+
if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) {
2841+
expect(
2842+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0],
2843+
).toBeLessThan(answerDraftStream.clear.mock.invocationCallOrder[0]);
2844+
}
2845+
const rotationOrder =
2846+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0];
28222847
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
2823-
expect(clearOrder).toBeLessThan(rotationOrder);
28242848
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
28252849
});
28262850

@@ -2841,12 +2865,19 @@ describe("dispatchTelegramMessage draft streaming", () => {
28412865
expect.objectContaining({ text: expect.stringMatching(/🛠 Exec<\/b>$/) }),
28422866
);
28432867
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Branch is up to date");
2844-
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
2845-
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
2846-
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
2847-
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
2868+
// Across an assistant boundary the tool-progress window still repositions
2869+
// (new message first, deferred delete) rather than deleting immediately.
2870+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
2871+
// The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2872+
// so that clear finds no live message id and never deletes the window.
2873+
if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) {
2874+
expect(
2875+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0],
2876+
).toBeLessThan(answerDraftStream.clear.mock.invocationCallOrder[0]);
2877+
}
2878+
const rotationOrder =
2879+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0];
28482880
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[0];
2849-
expect(clearOrder).toBeLessThan(rotationOrder);
28502881
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
28512882
});
28522883

@@ -2862,12 +2893,19 @@ describe("dispatchTelegramMessage draft streaming", () => {
28622893

28632894
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "🛠️ Exec: pnpm test");
28642895
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Tests passed");
2865-
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
2866-
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
2867-
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
2868-
const rotationOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
2896+
// Verbose tool result window repositions before the final: new message
2897+
// first, superseded delete deferred (no immediate clear/delete).
2898+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
2899+
// The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2900+
// so that clear finds no live message id and never deletes the window.
2901+
if (answerDraftStream.clear.mock.invocationCallOrder.length > 0) {
2902+
expect(
2903+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0],
2904+
).toBeLessThan(answerDraftStream.clear.mock.invocationCallOrder[0]);
2905+
}
2906+
const rotationOrder =
2907+
answerDraftStream.rotateToNewMessageDeferringDelete.mock.invocationCallOrder[0];
28692908
const finalUpdateOrder = answerDraftStream.update.mock.invocationCallOrder[1];
2870-
expect(clearOrder).toBeLessThan(rotationOrder);
28712909
expect(rotationOrder).toBeLessThan(finalUpdateOrder);
28722910
});
28732911

@@ -3058,6 +3096,42 @@ describe("dispatchTelegramMessage draft streaming", () => {
30583096
expect(texts).toContain("Done");
30593097
});
30603098

3099+
it("repositions the tool-progress window (deferred delete) when text follows durable reasoning", async () => {
3100+
// on-off mid-stream jump: a durable 🧠 posts BELOW the tool-progress window;
3101+
// when answer text then takes over the lane, the window must reposition
3102+
// (send-new-first, delete-old-deferred) rather than delete-then-repost,
3103+
// which scroll-jumps the Telegram client.
3104+
loadSessionStore.mockReturnValue({ s1: { reasoningLevel: "on" } });
3105+
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
3106+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
3107+
async ({ dispatcherOptions, replyOptions }) => {
3108+
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
3109+
await dispatcherOptions.deliver(
3110+
{ text: "<think>hidden</think>", isReasoning: true },
3111+
{ kind: "block" },
3112+
);
3113+
// Answer text mid-turn takes the lane over from the tool-progress window.
3114+
await dispatcherOptions.deliver({ text: "Here is the answer" }, { kind: "block" });
3115+
await dispatcherOptions.deliver({ text: "Here is the answer." }, { kind: "final" });
3116+
return { queuedFinal: true };
3117+
},
3118+
);
3119+
3120+
await dispatchWithContext({
3121+
context: createContext({
3122+
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
3123+
}),
3124+
streamMode: "progress",
3125+
telegramCfg: { streaming: { mode: "progress" } },
3126+
});
3127+
3128+
// The tool-progress window was repositioned via the deferred-delete path,
3129+
// never an immediate clear() (which deletes the window above the durable 🧠
3130+
// and reposts below — the focus-jump).
3131+
expect(answerDraftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalled();
3132+
expect(answerDraftStream.clear).not.toHaveBeenCalled();
3133+
});
3134+
30613135
it("posts the collapse bar durably with no delete when the window has no live message", async () => {
30623136
// When finalizeToPreview cannot edit in place (no live window message id),
30633137
// the bar is still surfaced — as a durable post — and the window is NOT
@@ -3968,9 +4042,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
39684042
"<b>Shelling</b>\n<b>🔎 Web Search</b> <code>docs lookup</code>\n<b>Update</b> <code>tests passed</code>",
39694043
),
39704044
);
3971-
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
39724045
expect(draftStream.materialize).not.toHaveBeenCalled();
3973-
expect(draftStream.clear).toHaveBeenCalledTimes(1);
4046+
// A tool-progress-only window with nothing to summarize is torn down via the
4047+
// deferred-delete reposition (new content first, delete later), not a bare
4048+
// immediate clear/delete or forceNewMessage.
4049+
expect(draftStream.rotateToNewMessageDeferringDelete).toHaveBeenCalledTimes(1);
4050+
expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
4051+
expect(draftStream.clear).not.toHaveBeenCalled();
39744052
expectDeliveredReply(0, { text: "Final after tool" });
39754053
expect(editMessageTelegram).not.toHaveBeenCalled();
39764054
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,8 +1237,15 @@ export const dispatchTelegramMessage = async ({
12371237
if (!activeAnswerDraftIsToolProgressOnly) {
12381238
return false;
12391239
}
1240-
await answerLane.stream?.clear();
1241-
answerLane.stream?.forceNewMessage();
1240+
// Reposition, don't delete-then-repost: rewind so the replacement message
1241+
// sends below, and defer the tool-progress window's delete until after it
1242+
// lands. Deleting first (clear) scroll-jumps the client when a durable 🧠
1243+
// was posted between the window and the replacement (the on-off jump).
1244+
if (answerLane.stream?.rotateToNewMessageDeferringDelete) {
1245+
answerLane.stream.rotateToNewMessageDeferringDelete();
1246+
} else {
1247+
answerLane.stream?.forceNewMessage();
1248+
}
12421249
resetDraftLaneState(answerLane);
12431250
suppressProgressDraftState();
12441251
rotateAnswerLaneWhenQueuedBlocksSettle = false;

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type TestDraftStream = {
1818
typeof vi.fn<(preview: TelegramDraftPreview) => Promise<number | undefined>>
1919
>;
2020
forceNewMessage: ReturnType<typeof vi.fn<() => void>>;
21+
rotateToNewMessageDeferringDelete: ReturnType<typeof vi.fn<() => number | undefined>>;
2122
sendMayHaveLanded: ReturnType<typeof vi.fn<() => boolean>>;
2223
setMessageId: (value: number | undefined) => void;
2324
};
@@ -85,6 +86,18 @@ export function createTestDraftStream(params?: {
8586
}
8687
visibleSinceMs = undefined;
8788
}),
89+
rotateToNewMessageDeferringDelete: vi.fn().mockImplementation(() => {
90+
// Mirror forceNewMessage's message-id handling (a sequenced harness swaps
91+
// ids on the next send; the fixed harness keeps its id unless configured
92+
// otherwise) so the rewind semantics match; return the superseded id.
93+
const superseded = messageId;
94+
stopped = false;
95+
if (params?.clearMessageIdOnForceNew) {
96+
messageId = undefined;
97+
}
98+
visibleSinceMs = undefined;
99+
return superseded;
100+
}),
88101
sendMayHaveLanded: vi.fn().mockReturnValue(false),
89102
setMessageId: (value: number | undefined) => {
90103
messageId = value;
@@ -137,6 +150,12 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
137150
activeMessageId = undefined;
138151
visibleSinceMs = undefined;
139152
}),
153+
rotateToNewMessageDeferringDelete: vi.fn().mockImplementation(() => {
154+
const superseded = activeMessageId;
155+
activeMessageId = undefined;
156+
visibleSinceMs = undefined;
157+
return superseded;
158+
}),
140159
sendMayHaveLanded: vi.fn().mockReturnValue(false),
141160
setMessageId: (value: number | undefined) => {
142161
activeMessageId = value;

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,48 @@ describe("createTelegramDraftStream", () => {
380380
}
381381
});
382382

383+
it("rotateToNewMessageDeferringDelete posts the new message before deleting the old", async () => {
384+
vi.useFakeTimers();
385+
try {
386+
const api = createMockDraftApi();
387+
api.sendMessage
388+
.mockResolvedValueOnce({ message_id: 17 })
389+
.mockResolvedValueOnce({ message_id: 42 });
390+
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
391+
392+
stream.update("🛠️ Exec");
393+
await stream.flush();
394+
// Reposition: rewind for a new message; the old one's delete is deferred.
395+
const superseded = stream.rotateToNewMessageDeferringDelete();
396+
expect(superseded).toBe(17);
397+
398+
// The NEW message is sent first...
399+
stream.update("Answer below");
400+
await stream.flush();
401+
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Answer below", {
402+
message_thread_id: 42,
403+
});
404+
// ...and the superseded message is NOT deleted immediately (deferred so
405+
// the new message lands first — no scroll-jump).
406+
expect(api.deleteMessage).not.toHaveBeenCalled();
407+
408+
await vi.advanceTimersByTimeAsync(4_000);
409+
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
410+
// Only the superseded (old) message is deleted; the new one stays.
411+
expect(api.deleteMessage).toHaveBeenCalledTimes(1);
412+
} finally {
413+
vi.useRealTimers();
414+
}
415+
});
416+
417+
it("rotateToNewMessageDeferringDelete is a no-op with no live message", () => {
418+
const api = createMockDraftApi();
419+
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
420+
421+
expect(stream.rotateToNewMessageDeferringDelete()).toBeUndefined();
422+
expect(api.deleteMessage).not.toHaveBeenCalled();
423+
});
424+
383425
it("creates new message after forceNewMessage is called", async () => {
384426
const { api, stream } = createForceNewMessageHarness();
385427

extensions/telegram/src/draft-stream.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export type TelegramDraftStream = {
6868
finalizeToPreview: (preview: TelegramDraftPreview) => Promise<number | undefined>;
6969
/** Reset internal state so the next update creates a new message instead of editing. */
7070
forceNewMessage: () => void;
71+
/**
72+
* Reposition the window: rewind so the next update creates a new message,
73+
* and schedule the superseded message's delete for AFTER the new one lands
74+
* (post-new-then-delete-old, never delete-then-repost — avoids the client
75+
* scroll-jump). Returns the superseded message id, if any.
76+
*/
77+
rotateToNewMessageDeferringDelete: () => number | undefined;
7178
/** True when a preview sendMessage was attempted but the response was lost. */
7279
sendMayHaveLanded?: () => boolean;
7380
};
@@ -546,6 +553,37 @@ export function createTelegramDraftStream(params: {
546553
loop.resetThrottleWindow();
547554
};
548555

556+
// Delete a superseded preview message DETACHED (scheduled, never awaited) so
557+
// teardown is never stalled. The delay is at least the remaining on-screen
558+
// dwell (so a preview is never flashed), and at least `minDelayMs` — a
559+
// reposition passes a small floor so the NEW message has landed below before
560+
// the old one disappears, keeping the viewport anchored instead of jumping.
561+
const scheduleDetachedDelete = (
562+
messageId: number,
563+
visibleSince: number | undefined,
564+
minDelayMs = 0,
565+
) => {
566+
const runDelete = async () => {
567+
try {
568+
await params.api.deleteMessage(chatId, messageId);
569+
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
570+
} catch (err) {
571+
params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`);
572+
}
573+
};
574+
const elapsedMs =
575+
typeof visibleSince === "number" ? Date.now() - visibleSince : MIN_PREVIEW_DWELL_MS;
576+
const remainingDwellMs = Math.max(0, MIN_PREVIEW_DWELL_MS - elapsedMs);
577+
const delayMs = Math.max(remainingDwellMs, minDelayMs);
578+
if (delayMs <= 0) {
579+
void runDelete();
580+
} else {
581+
setTimeout(() => {
582+
void runDelete();
583+
}, delayMs);
584+
}
585+
};
586+
549587
const clear = async () => {
550588
// Capture before the stop; takeMessageIdAfterStop resets streamVisibleSinceMs.
551589
const visibleSince = streamVisibleSinceMs;
@@ -557,28 +595,28 @@ export function createTelegramDraftStream(params: {
557595
},
558596
});
559597
if (typeof messageId === "number" && Number.isFinite(messageId)) {
560-
const runDelete = async () => {
561-
try {
562-
await params.api.deleteMessage(chatId, messageId);
563-
params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`);
564-
} catch (err) {
565-
params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`);
566-
}
567-
};
568598
// Keep the preview on screen for at least MIN_PREVIEW_DWELL_MS from when it
569-
// first appeared, then delete DETACHED (scheduled, not awaited) so teardown
570-
// is never stalled waiting for the dwell.
571-
const elapsedMs =
572-
typeof visibleSince === "number" ? Date.now() - visibleSince : MIN_PREVIEW_DWELL_MS;
573-
const remainingMs = Math.max(0, MIN_PREVIEW_DWELL_MS - elapsedMs);
574-
if (remainingMs <= 0) {
575-
void runDelete();
576-
} else {
577-
setTimeout(() => {
578-
void runDelete();
579-
}, remainingMs);
580-
}
599+
// first appeared, then delete.
600+
scheduleDetachedDelete(messageId, visibleSince);
601+
}
602+
};
603+
604+
// Reposition the window: rewind so the NEXT update creates a fresh message
605+
// (below anything posted since), then delete the superseded one AFTER a short
606+
// delay so the new message lands first. Post-new-then-delete-old — never
607+
// delete-then-repost, which scroll-jumps the Telegram client (the on-off
608+
// durable-🧠 jump). Returns the superseded message id (for tests).
609+
const REPOSITION_DELETE_DELAY_MS = 1_500;
610+
const rotateToNewMessageDeferringDelete = (): number | undefined => {
611+
const supersededMessageId = streamMessageId;
612+
const supersededVisibleSince = streamVisibleSinceMs;
613+
// Rewind WITHOUT deleting; the old id is captured above.
614+
resetStreamToNewMessage();
615+
if (typeof supersededMessageId === "number" && Number.isFinite(supersededMessageId)) {
616+
scheduleDetachedDelete(supersededMessageId, supersededVisibleSince, REPOSITION_DELETE_DELAY_MS);
617+
return supersededMessageId;
581618
}
619+
return undefined;
582620
};
583621

584622
const discard = async () => {
@@ -646,6 +684,7 @@ export function createTelegramDraftStream(params: {
646684
materialize,
647685
finalizeToPreview,
648686
forceNewMessage,
687+
rotateToNewMessageDeferringDelete,
649688
sendMayHaveLanded: () => messageSendAttempted && typeof streamMessageId !== "number",
650689
};
651690
}

0 commit comments

Comments
 (0)