From 6fb082e13160cfa7282c587a225d80b0e8c770bd Mon Sep 17 00:00:00 2001 From: codexGW <9350182+codexGW@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:53:38 -0800 Subject: [PATCH] fix(typing): call markDispatchIdle in followup runner to prevent stuck indicator (#26881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The followup runner (used for queued messages, inter-agent sends, heartbeat followups, etc.) only called typing.markRunComplete() in its finally block. The typing controller requires BOTH markRunComplete AND markDispatchIdle to trigger cleanup — but markDispatchIdle was only wired through the buffered dispatcher path, which followup turns bypass entirely. This caused the typing indicator to persist indefinitely on channels like Telegram when the agent replied with NO_REPLY or produced empty payloads, because the keepalive loop was never stopped. Adds markDispatchIdle() alongside markRunComplete() in the followup runner's finally block, and four test cases covering NO_REPLY, empty payloads, agent errors, and successful delivery. Complements #26295 which addressed the channel-level callback layer. Fixes #26595 Co-authored-by: Samantha --- src/auto-reply/reply/followup-runner.test.ts | 81 ++++++++++++++++++++ src/auto-reply/reply/followup-runner.ts | 8 ++ 2 files changed, 89 insertions(+) diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index da5d55fa9dd..a6e0c9f849a 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -428,6 +428,87 @@ describe("createFollowupRunner messaging tool dedupe", () => { }); }); +describe("createFollowupRunner typing cleanup", () => { + it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "NO_REPLY" }], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on agent error", async () => { + const typing = createMockTypingController(); + runEmbeddedPiAgentMock.mockRejectedValueOnce(new Error("agent exploded")); + + const runner = createFollowupRunner({ + opts: { onBlockReply: vi.fn(async () => {}) }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); + + it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => { + const typing = createMockTypingController(); + const onBlockReply = vi.fn(async () => {}); + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "hello world!" }], + meta: {}, + }); + + const runner = createFollowupRunner({ + opts: { onBlockReply }, + typing, + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-5", + }); + + await runner(baseQueuedRun()); + + expect(onBlockReply).toHaveBeenCalled(); + expect(typing.markRunComplete).toHaveBeenCalled(); + expect(typing.markDispatchIdle).toHaveBeenCalled(); + }); +}); + describe("createFollowupRunner agentDir forwarding", () => { it("passes queued run agentDir to runEmbeddedPiAgent", async () => { runEmbeddedPiAgentMock.mockClear(); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 0c91d543d91..3f280d18e52 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -314,7 +314,15 @@ export function createFollowupRunner(params: { await sendFollowupPayloads(finalPayloads, queued); } finally { + // Both signals are required for the typing controller to clean up. + // The main inbound dispatch path calls markDispatchIdle() from the + // buffered dispatcher's finally block, but followup turns bypass the + // dispatcher entirely — so we must fire both signals here. Without + // this, NO_REPLY / empty-payload followups leave the typing indicator + // stuck (the keepalive loop keeps sending "typing" to Telegram + // indefinitely until the TTL expires). typing.markRunComplete(); + typing.markDispatchIdle(); } }; }