fix(typing): call markDispatchIdle in followup runner to prevent stuck indicator (#26881)

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 <samantha@Samanthas-Mac-mini.local>
This commit is contained in:
codexGW
2026-02-25 16:53:38 -08:00
committed by GitHub
parent 70e31c6f68
commit 6fb082e131
2 changed files with 89 additions and 0 deletions

View File

@@ -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();