Add pending-token API to InputDebouncer

This commit is contained in:
EugeneTes
2026-09-23 07:05:09 +00:00
parent 76bbac2df9
commit 98e1ed2613
2 changed files with 121 additions and 3 deletions

View File

@@ -71,6 +71,99 @@ describe("InputDebouncer", () => {
vi.advanceTimersByTime(1000);
expect(flushed).toEqual([]);
});
it("beginPending after an add cancels the armed timer until endPending", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 200, onFlush: (i) => flushed.push(i) });
d.add({ text: "hi" });
d.beginPending();
vi.advanceTimersByTime(500);
expect(flushed).toEqual([]);
d.endPending();
vi.advanceTimersByTime(199);
expect(flushed).toEqual([]);
vi.advanceTimersByTime(1);
expect(flushed).toEqual([{ text: "hi", images: undefined }]);
});
it("add during pending does not arm the timer; endPending arms it", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 100, onFlush: (i) => flushed.push(i) });
d.beginPending();
d.add({ text: "buffered" });
vi.advanceTimersByTime(500);
expect(flushed).toEqual([]);
d.endPending();
vi.advanceTimersByTime(100);
expect(flushed).toEqual([{ text: "buffered", images: undefined }]);
});
it("requires one endPending per beginPending before the timer arms", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 100, onFlush: (i) => flushed.push(i) });
d.beginPending();
d.beginPending();
d.add({ text: "one" });
d.add({ text: "two" });
d.endPending();
vi.advanceTimersByTime(500);
expect(flushed).toEqual([]);
d.endPending();
vi.advanceTimersByTime(100);
expect(flushed).toEqual([{ text: "one\n\ntwo", images: undefined }]);
});
it("beginPending / endPending with no add does not fire", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 50, onFlush: (i) => flushed.push(i) });
d.beginPending();
d.endPending();
vi.advanceTimersByTime(1000);
expect(flushed).toEqual([]);
});
it("simulates the comment-then-audio race and delivers one merged flush", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 100, onFlush: (i) => flushed.push(i) });
// Comment handler starts.
d.beginPending();
// Audio handler starts a beat later.
d.beginPending();
// Comment handler finishes fast: adds, releases.
d.add({ text: "look at this" });
d.endPending();
// A long transcription window passes; still pending, so no flush.
vi.advanceTimersByTime(3000);
expect(flushed).toEqual([]);
// Audio handler finishes: adds, releases.
d.add({ text: "[Forwarded from @alice]\nhello everyone" });
d.endPending();
vi.advanceTimersByTime(100);
expect(flushed).toEqual([
{ text: "look at this\n\n[Forwarded from @alice]\nhello everyone", images: undefined },
]);
});
it("discard while a token is outstanding leaves the timer unarmed after release", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 100, onFlush: (i) => flushed.push(i) });
d.beginPending();
d.add({ text: "gone" });
d.discard();
d.endPending();
vi.advanceTimersByTime(1000);
expect(flushed).toEqual([]);
});
it("extra endPending calls are ignored (pending never goes negative)", () => {
const flushed: AgentInput[] = [];
const d = new InputDebouncer({ windowMs: 100, onFlush: (i) => flushed.push(i) });
d.endPending();
d.endPending();
d.add({ text: "hi" });
vi.advanceTimersByTime(100);
expect(flushed).toEqual([{ text: "hi", images: undefined }]);
});
});
describe("merge", () => {

View File

@@ -10,18 +10,37 @@ export interface InputDebouncerOptions {
* input once the window goes quiet. Telegram sends "forward with a comment" as
* two separate messages (the comment, then the forwarded content); this stitches
* them together so the agent sees one turn.
*
* The pending counter lets callers announce "I'm about to prepare an input"
* before an async step (e.g. audio transcription). While `pending > 0` the
* flush timer is never armed, so a fast handler cannot flush while a slow one
* is still awaiting. Pair every `beginPending()` with `endPending()` in a
* `try/finally`.
*/
export class InputDebouncer {
private buffer: AgentInput[] = [];
private timer: NodeJS.Timeout | undefined;
private pending = 0;
constructor(private readonly opts: InputDebouncerOptions) {}
add(input: AgentInput): void {
this.buffer.push(input);
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.flush(), this.opts.windowMs);
this.timer.unref?.();
if (this.pending === 0) this.armTimer();
}
beginPending(): void {
this.pending++;
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
}
endPending(): void {
if (this.pending === 0) return;
this.pending--;
if (this.pending === 0 && this.buffer.length > 0) this.armTimer();
}
/** Flushes immediately (e.g. on shutdown or when the caller wants to force delivery). */
@@ -38,6 +57,12 @@ export class InputDebouncer {
this.buffer = [];
}
private armTimer(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.flush(), this.opts.windowMs);
this.timer.unref?.();
}
private flush(): void {
this.timer = undefined;
if (this.buffer.length === 0) return;