fix(typing): stop keepalive restarts after run completion (land #27413, thanks @widingmarcus-cyber)

Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-26 11:41:38 +00:00
parent fec3fdf7ef
commit 8bf1c9a23a
4 changed files with 90 additions and 2 deletions

View File

@@ -142,7 +142,7 @@ describe("typing controller", () => {
typing.markDispatchIdle();
}
await vi.advanceTimersByTimeAsync(2_000);
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5);
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
if (testCase.second === "run") {
typing.markRunComplete();
@@ -150,7 +150,7 @@ describe("typing controller", () => {
typing.markDispatchIdle();
}
await vi.advanceTimersByTimeAsync(2_000);
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5);
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
}
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { createTypingController } from "./typing.js";
describe("typing persistence bug fix", () => {
let onReplyStartSpy: Mock;
let onCleanupSpy: Mock;
let controller: ReturnType<typeof createTypingController>;
beforeEach(() => {
vi.useFakeTimers();
onReplyStartSpy = vi.fn();
onCleanupSpy = vi.fn();
controller = createTypingController({
onReplyStart: onReplyStartSpy,
onCleanup: onCleanupSpy,
typingIntervalSeconds: 6,
log: vi.fn(),
});
});
afterEach(() => {
vi.useRealTimers();
});
it("should NOT restart typing after markRunComplete is called", async () => {
// Start typing normally
await controller.startTypingLoop();
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
// Mark run as complete (but not yet dispatch idle)
controller.markRunComplete();
// Advance time to trigger the typing interval (6 seconds)
vi.advanceTimersByTime(6000);
// BUG: The typing loop should NOT call onReplyStart again
// because the run is already complete
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
expect(onReplyStartSpy).not.toHaveBeenCalledTimes(2);
});
it("should stop typing when both runComplete and dispatchIdle are true", async () => {
// Start typing
await controller.startTypingLoop();
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
// Mark run complete
controller.markRunComplete();
expect(onCleanupSpy).not.toHaveBeenCalled();
// Mark dispatch idle - should trigger cleanup
controller.markDispatchIdle();
expect(onCleanupSpy).toHaveBeenCalledTimes(1);
// After cleanup, typing interval should not restart typing
vi.advanceTimersByTime(6000);
expect(onReplyStartSpy).toHaveBeenCalledTimes(1); // Still only the initial call
});
it("should prevent typing restart even if cleanup is delayed", async () => {
// Start typing
await controller.startTypingLoop();
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
// Mark run complete (but dispatch not idle yet - simulating cleanup delay)
controller.markRunComplete();
// Multiple typing intervals should NOT restart typing
vi.advanceTimersByTime(6000); // First interval
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(6000); // Second interval
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(6000); // Third interval
expect(onReplyStartSpy).toHaveBeenCalledTimes(1);
// Eventually dispatch becomes idle and triggers cleanup
controller.markDispatchIdle();
expect(onCleanupSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -99,6 +99,10 @@ export function createTypingController(params: {
if (sealed) {
return;
}
// Late callbacks after a run completed should never restart typing.
if (runComplete) {
return;
}
await onReplyStart?.();
};