From 45b86450795d8bd3d1e548c8815cd97d6583199d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 1 May 2026 07:34:30 +0530 Subject: [PATCH] fix(channels): keep typing indicators off reply critical path --- src/auto-reply/reply/typing.ts | 24 ++++++++++++++++++++---- src/channels/typing.test.ts | 21 +++++++++++++++++++++ src/channels/typing.ts | 15 +++++++++------ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 8ab76d3fde3..f5f3aed13b6 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -46,6 +46,7 @@ export function createTypingController(params: { let active = false; let runComplete = false; let dispatchIdle = false; + let triggerInFlight = false; // Important: callbacks (tool/block streaming) can fire late (after the run completed), // especially when upstream event emitters don't await async listeners. // Once we stop typing, we "seal" the controller so late events can't restart typing forever. @@ -120,9 +121,24 @@ export function createTypingController(params: { }); const triggerTyping = async () => { - await startGuard.run(async () => { - await onReplyStart?.(); - }); + if (triggerInFlight) { + return; + } + triggerInFlight = true; + try { + await startGuard.run(async () => { + await onReplyStart?.(); + }); + } catch (err) { + log?.(`typing start failed: ${String(err)}`); + } finally { + triggerInFlight = false; + } + }; + + const scheduleTyping = async () => { + void triggerTyping(); + await Promise.resolve(); }; const typingLoop = createTypingKeepaliveLoop({ @@ -145,7 +161,7 @@ export function createTypingController(params: { return; } started = true; - await triggerTyping(); + await scheduleTyping(); }; const maybeStopOnIdle = () => { diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts index 3c398b2b01c..d8ddae0a592 100644 --- a/src/channels/typing.test.ts +++ b/src/channels/typing.test.ts @@ -67,10 +67,28 @@ describe("createTypingCallbacks", () => { }); await callbacks.onReplyStart(); + await flushMicrotasks(); expect(onStartError).toHaveBeenCalledTimes(1); }); + it("does not block reply start on a pending typing request", async () => { + let resolveStart!: () => void; + const { start, callbacks } = createTypingHarness({ + start: vi.fn( + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + ), + }); + + await callbacks.onReplyStart(); + + expect(start).toHaveBeenCalledTimes(1); + resolveStart(); + }); + it("invokes stop on idle and reports stop errors", async () => { const { stop, onStopError, callbacks } = createTypingHarness({ stop: vi.fn().mockRejectedValue(new Error("stop")), @@ -113,6 +131,7 @@ describe("createTypingCallbacks", () => { start: vi.fn().mockRejectedValue(new Error("gone")), }); await callbacks.onReplyStart(); + await flushMicrotasks(); expect(start).toHaveBeenCalledTimes(1); expect(onStartError).toHaveBeenCalledTimes(1); @@ -133,6 +152,7 @@ describe("createTypingCallbacks", () => { }); await callbacks.onReplyStart(); + await flushMicrotasks(); expect(start).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(9_000); @@ -154,6 +174,7 @@ describe("createTypingCallbacks", () => { maxConsecutiveFailures: 2, }); await callbacks.onReplyStart(); // fail + await flushMicrotasks(); await vi.advanceTimersByTimeAsync(3_000); // success await vi.advanceTimersByTimeAsync(3_000); // fail await vi.advanceTimersByTimeAsync(3_000); // success diff --git a/src/channels/typing.ts b/src/channels/typing.ts index 5d2a5c2e100..c28e2b153d9 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -76,12 +76,15 @@ export function createTypingCallbacks(params: CreateTypingCallbacksParams): Typi startGuard.reset(); keepaliveLoop.stop(); clearTtlTimer(); - await fireStart(); - if (startGuard.isTripped()) { - return; - } - keepaliveLoop.start(); - startTtlTimer(); // Start TTL safety timer + const startPromise = fireStart(); + void startPromise.then(() => { + if (closed || startGuard.isTripped()) { + return; + } + keepaliveLoop.start(); + startTtlTimer(); + }); + await Promise.resolve(); }; const fireStop = () => {