diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index 678aba22d9b5..8785cc97148e 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -313,18 +313,27 @@ describe("createTelegramDraftStream", () => { }); it("deletes message preview on clear after finalization", async () => { - const api = createMockDraftApi(); - const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" }); + vi.useFakeTimers(); + try { + const api = createMockDraftApi(); + const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" }); - stream.update("Hello"); - await stream.flush(); - stream.update("Hello again"); - await stream.stop(); - await stream.clear(); + stream.update("Hello"); + await stream.flush(); + stream.update("Hello again"); + await stream.stop(); + await stream.clear(); - expectPreviewSend(api, "Hello", { message_thread_id: 42 }); - expectPreviewEdit(api, "Hello again"); - expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); + expectPreviewSend(api, "Hello", { message_thread_id: 42 }); + expectPreviewEdit(api, "Hello again"); + // The delete is deferred until the preview has been on screen for the + // dwell window; advance past it to trigger the detached cleanup. + expect(api.deleteMessage).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(4_000); + expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); + } finally { + vi.useRealTimers(); + } }); it("creates new message after forceNewMessage is called", async () => { @@ -351,20 +360,50 @@ describe("createTelegramDraftStream", () => { }); it("creates new message after cleanup and forceNewMessage", async () => { - const { api, stream } = createForceNewMessageHarness(); + vi.useFakeTimers(); + try { + const { api, stream } = createForceNewMessageHarness(); - stream.update("Stale preview"); - await stream.flush(); + stream.update("Stale preview"); + await stream.flush(); - await stream.clear(); - expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); + await stream.clear(); + // Delete is deferred past the dwell window; advance to trigger it. + await vi.advanceTimersByTimeAsync(4_000); + expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); - stream.forceNewMessage(); - stream.update("Next preview"); - await stream.flush(); + stream.forceNewMessage(); + stream.update("Next preview"); + await stream.flush(); - expect(api.sendMessage).toHaveBeenCalledTimes(2); - expectNthPreviewSend(api, 2, "Next preview"); + expect(api.sendMessage).toHaveBeenCalledTimes(2); + expectNthPreviewSend(api, 2, "Next preview"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the streaming preview on screen for the dwell window before deleting", async () => { + vi.useFakeTimers(); + try { + const api = createMockDraftApi(); + const stream = createDraftStream(api); + + stream.update("Working"); + await stream.flush(); + // Fast turn: the preview has only been visible ~1s when the turn tears down. + await vi.advanceTimersByTimeAsync(1_000); + await stream.clear(); + + // Delete is deferred, not synchronous, and does not fire before the 4s dwell. + await vi.advanceTimersByTimeAsync(2_000); + expect(api.deleteMessage).not.toHaveBeenCalled(); + // At the dwell boundary (~4s after first appearing) the detached delete runs. + await vi.advanceTimersByTimeAsync(1_000); + expect(api.deleteMessage).toHaveBeenCalledWith(123, 17); + } finally { + vi.useRealTimers(); + } }); it("sends first update immediately after forceNewMessage within throttle window", async () => { diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index a9ed0fa5b695..78ed1d9d7208 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -37,6 +37,13 @@ const MAX_CONSECUTIVE_PREVIEW_FAILURES = 3; // Flood waits beyond this freeze the preview longer than it is useful; clamp so // a large retry_after cannot park the suspension past the run's lifetime. const MAX_PREVIEW_FLOOD_SUSPEND_MS = 60_000; +// Minimum time the streaming preview ("gerund" box) stays on screen before it +// is deleted at teardown, measured from when it first became visible. On fast +// turns the box otherwise flashed and vanished before it could be read, and the +// immediate delete could race a just-persisted message (intermittently dropping +// the first verbose commentary). The delete is scheduled DETACHED so the turn is +// never stalled waiting on the dwell. +const MIN_PREVIEW_DWELL_MS = 4_000; export type TelegramDraftStream = { update: (text: string) => void; @@ -533,6 +540,8 @@ export function createTelegramDraftStream(params: { }; const clear = async () => { + // Capture before the stop; takeMessageIdAfterStop resets streamVisibleSinceMs. + const visibleSince = streamVisibleSinceMs; const messageId = await takeMessageIdAfterStop({ stopForClear, readMessageId: () => streamMessageId, @@ -541,11 +550,26 @@ export function createTelegramDraftStream(params: { }, }); if (typeof messageId === "number" && Number.isFinite(messageId)) { - try { - await params.api.deleteMessage(chatId, messageId); - params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`); - } catch (err) { - params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`); + const runDelete = async () => { + try { + await params.api.deleteMessage(chatId, messageId); + params.log?.(`telegram stream preview deleted (chat=${chatId}, message=${messageId})`); + } catch (err) { + params.warn?.(`telegram stream preview cleanup failed: ${formatErrorMessage(err)}`); + } + }; + // Keep the preview on screen for at least MIN_PREVIEW_DWELL_MS from when it + // first appeared, then delete DETACHED (scheduled, not awaited) so teardown + // is never stalled waiting for the dwell. + const elapsedMs = + typeof visibleSince === "number" ? Date.now() - visibleSince : MIN_PREVIEW_DWELL_MS; + const remainingMs = Math.max(0, MIN_PREVIEW_DWELL_MS - elapsedMs); + if (remainingMs <= 0) { + void runDelete(); + } else { + setTimeout(() => { + void runDelete(); + }, remainingMs); } } };