diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index c1f314183b8..b68a1976491 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -85,4 +85,28 @@ describe("createTypingCallbacks", () => { expect(stop).toHaveBeenCalledTimes(1); }); + + it("does not restart keepalive after idle cleanup", async () => { + vi.useFakeTimers(); + try { + const start = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + const onStartError = vi.fn(); + const callbacks = createTypingCallbacks({ start, stop, onStartError }); + + await callbacks.onReplyStart(); + expect(start).toHaveBeenCalledTimes(1); + + callbacks.onIdle?.(); + await flushMicrotasks(); + + await callbacks.onReplyStart(); + await vi.advanceTimersByTimeAsync(9_000); + + expect(start).toHaveBeenCalledTimes(1); + expect(stop).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/channels/typing.ts b/src/channels/typing.ts index b701dfb72cd..531c2fdc485 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -17,6 +17,7 @@ export function createTypingCallbacks(params: { const stop = params.stop; const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000; let stopSent = false; + let closed = false; const fireStart = async () => { try { @@ -32,6 +33,9 @@ export function createTypingCallbacks(params: { }); const onReplyStart = async () => { + if (closed) { + return; + } stopSent = false; keepaliveLoop.stop(); await fireStart(); @@ -39,6 +43,7 @@ export function createTypingCallbacks(params: { }; const fireStop = () => { + closed = true; keepaliveLoop.stop(); if (!stop || stopSent) { return; diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 00124709c74..b6a73bc18a2 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -723,12 +723,18 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) dispatchError = true; throw err; } finally { - // Must stop() first to flush debounced content before clear() wipes state - await draftStream?.stop(); - if (!finalizedViaPreviewMessage) { - await draftStream?.clear(); + try { + // Must stop() first to flush debounced content before clear() wipes state. + await draftStream?.stop(); + if (!finalizedViaPreviewMessage) { + await draftStream?.clear(); + } + } catch (err) { + // Draft cleanup should never keep typing alive. + logVerbose(`discord: draft cleanup failed: ${String(err)}`); + } finally { + markDispatchIdle(); } - markDispatchIdle(); if (statusReactionsEnabled) { if (dispatchError) { await statusReactions.setError();