Add implementation plan for forwarded audio + comment merge
Three TDD tasks: pending-token API on the debouncer, bracket the bridge message handler, and one manual E2E line in the README.
This commit is contained in:
437
docs/superpowers/plans/2026-09-23-audio-comment-debounce-race.md
Normal file
437
docs/superpowers/plans/2026-09-23-audio-comment-debounce-race.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# Audio-comment debounce race fix — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the Telegram bridge deliver a forwarded audio message and its accompanying text comment as a single agent turn instead of two, by teaching `InputDebouncer` to defer its flush timer while any message handler is still preparing its input.
|
||||
|
||||
**Architecture:** Add a `pending` counter to `InputDebouncer` with `beginPending()` / `endPending()` methods that suppress and re-arm the flush timer. Bracket the entire per-message handler in `bridge.ts` with `beginPending()` … `endPending()` in a `try/finally`, so a fast comment handler cannot flush while a slow audio-transcription handler is still awaiting the transcriber.
|
||||
|
||||
**Tech Stack:** TypeScript, Node.js, grammy, vitest (with `vi.useFakeTimers()` for debouncer unit tests; real timers + `vi.waitFor` in bridge tests).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-23-audio-comment-debounce-race-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/telegram/debouncer.ts` — add `pending: number`, `beginPending()`, `endPending()`; factor timer arming into a private `armTimer()`; wire pending-awareness into `add()`.
|
||||
- **Modify** `src/telegram/debouncer.test.ts` — add unit tests for the new pending-token behaviour.
|
||||
- **Modify** `src/telegram/bridge.ts` — bracket the `bot.on("message")` handler body with `beginPending()` / `endPending()`.
|
||||
- **Modify** `src/telegram/bridge.test.ts` — extend `harness()` to inject a stub `Transcriber`; add a concurrent-handling race test.
|
||||
- **Modify** `README.md` — append one line to the manual end-to-end checklist.
|
||||
|
||||
No new files. All changes are on `master` (no worktree needed for a change this small — the workflow rule to fetch is satisfied by the earlier `git pull`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Debouncer pending-token API (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/telegram/debouncer.ts`
|
||||
- Modify: `src/telegram/debouncer.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the new behaviour**
|
||||
|
||||
Append these tests inside the existing `describe("InputDebouncer", …)` block in `src/telegram/debouncer.test.ts`, after the existing `it("discard drops buffered items …")` and before the closing `});` of the block:
|
||||
|
||||
```ts
|
||||
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 }]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new tests and confirm they fail**
|
||||
|
||||
Run: `npm test -- src/telegram/debouncer.test.ts`
|
||||
|
||||
Expected: seven new failing tests (`d.beginPending is not a function` / `d.endPending is not a function`). Existing tests still pass.
|
||||
|
||||
- [ ] **Step 3: Implement `beginPending` / `endPending` and pending-awareness in `add`**
|
||||
|
||||
Replace the entire contents of `src/telegram/debouncer.ts` with:
|
||||
|
||||
```ts
|
||||
import type { AgentImage, AgentInput } from "../agent/events.js";
|
||||
|
||||
export interface InputDebouncerOptions {
|
||||
windowMs: number;
|
||||
onFlush: (input: AgentInput) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers AgentInputs arriving in quick succession and flushes a single merged
|
||||
* 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.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). */
|
||||
flushNow(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.flush();
|
||||
}
|
||||
|
||||
/** Drops the buffered items without delivering them. */
|
||||
discard(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
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;
|
||||
const merged = merge(this.buffer);
|
||||
this.buffer = [];
|
||||
this.opts.onFlush(merged);
|
||||
}
|
||||
}
|
||||
|
||||
export function merge(inputs: AgentInput[]): AgentInput {
|
||||
const parts: string[] = [];
|
||||
const images: AgentImage[] = [];
|
||||
for (const input of inputs) {
|
||||
if (input.text) parts.push(input.text);
|
||||
if (input.images) images.push(...input.images);
|
||||
}
|
||||
return { text: parts.join("\n\n"), images: images.length > 0 ? images : undefined };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the debouncer tests and confirm they pass**
|
||||
|
||||
Run: `npm test -- src/telegram/debouncer.test.ts`
|
||||
|
||||
Expected: all tests pass (the seven new ones plus the pre-existing suite).
|
||||
|
||||
- [ ] **Step 5: Run the full test suite and typecheck to confirm nothing else broke**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: green. `npm run typecheck` completes without errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/telegram/debouncer.ts src/telegram/debouncer.test.ts
|
||||
git commit -m "Add pending-token API to InputDebouncer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Bracket the bridge message handler with pending tokens (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/telegram/bridge.ts`
|
||||
- Modify: `src/telegram/bridge.test.ts`
|
||||
|
||||
- [ ] **Step 1: Extend `harness()` to accept a stub transcriber**
|
||||
|
||||
In `src/telegram/bridge.test.ts`, modify the `harness()` function signature and body to accept a `transcriber` option and pass it through to `registerBridge`. Replace the existing:
|
||||
|
||||
```ts
|
||||
function harness(overrides: { debounceMs?: number } = {}) {
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
function harness(overrides: { debounceMs?: number; transcriber?: import("../stt/openai.js").Transcriber } = {}) {
|
||||
```
|
||||
|
||||
Then, inside the body, in the `registerBridge({ … })` call, add `transcriber: overrides.transcriber,` next to the other deps (right after `logger,` is a natural spot):
|
||||
|
||||
```ts
|
||||
registerBridge({
|
||||
bot,
|
||||
config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" },
|
||||
host,
|
||||
scheduler,
|
||||
outbound,
|
||||
logger,
|
||||
transcriber: overrides.transcriber,
|
||||
debounceMs: overrides.debounceMs ?? 5,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Also stub the `getFile` return for `.oga` voice in the harness (already present) and add a `getFile` return for the specific test voice**
|
||||
|
||||
The harness already returns `voice/file_1.oga` for any `getFile` (see the existing `bot.api.config.use` block). No change needed here — leaving this step as a check: confirm `voice/file_1.oga` is what the harness's fake API returns and that the bridge's real `downloadFile` will then `fetch(...)` that URL. In tests we do not want a real HTTP fetch — we work around this by making the transcriber's `transcribe` method never depend on the download bytes (it just returns a preconfigured string). But `downloadFile` itself will still `fetch`, which in Node's test environment will try to hit `api.telegram.org`. Verify by looking at bridge.ts `downloadFile` — it accepts `fetchFn`. We therefore also need to inject `fetchFn`.
|
||||
|
||||
In the `registerBridge` call inside `harness()`, add a stub `fetchFn` too:
|
||||
|
||||
```ts
|
||||
registerBridge({
|
||||
bot,
|
||||
config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" },
|
||||
host,
|
||||
scheduler,
|
||||
outbound,
|
||||
logger,
|
||||
transcriber: overrides.transcriber,
|
||||
fetchFn: async () => new Response(new Uint8Array([0x4f, 0x67, 0x67, 0x53])), // "OggS"
|
||||
debounceMs: overrides.debounceMs ?? 5,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write the failing race test**
|
||||
|
||||
Add this test inside `describe("registerBridge", …)` in `src/telegram/bridge.test.ts`, immediately after the existing `it("merges a forward-with-comment pair into a single agent turn", …)`:
|
||||
|
||||
```ts
|
||||
it("merges a fast comment with a slow-transcribing forwarded voice into one agent turn", async () => {
|
||||
let resolveTranscription!: (text: string) => void;
|
||||
const transcription = new Promise<string>((r) => { resolveTranscription = r; });
|
||||
const transcriber = { transcribe: () => transcription };
|
||||
const h = harness({ debounceMs: 30, transcriber });
|
||||
|
||||
// Comment arrives first, resolves fast.
|
||||
const commentDone = h.bot.handleUpdate(textUpdate("about this voice note"));
|
||||
|
||||
// Forwarded voice message arrives right after, transcription blocks.
|
||||
const voiceUpdate = textUpdate("");
|
||||
delete (voiceUpdate.message as { text?: string }).text;
|
||||
(voiceUpdate.message as { voice?: unknown }).voice = { file_id: "v1", file_unique_id: "u", duration: 3, mime_type: "audio/ogg" };
|
||||
(voiceUpdate.message as { forward_origin?: unknown }).forward_origin = {
|
||||
type: "user",
|
||||
date: 0,
|
||||
sender_user: { id: 77, is_bot: false, first_name: "Alice", username: "alice" },
|
||||
};
|
||||
const voiceDone = h.bot.handleUpdate(voiceUpdate);
|
||||
|
||||
// Comment handler completes; audio handler is still awaiting the transcriber.
|
||||
await commentDone;
|
||||
|
||||
// Wait past the debounce window to prove the flush is being suppressed.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.host.sent).toEqual([]);
|
||||
|
||||
// Transcription resolves; audio handler unblocks and both flush together.
|
||||
resolveTranscription("buy milk");
|
||||
await voiceDone;
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(h.host.sent).toEqual([
|
||||
{ text: "about this voice note\n\n[Forwarded from @alice]\nbuy milk", images: undefined },
|
||||
]),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new test and confirm it fails**
|
||||
|
||||
Run: `npm test -- src/telegram/bridge.test.ts`
|
||||
|
||||
Expected: the new test fails. Depending on timing you should see either two entries in `h.host.sent` (the comment flushed on its own, then the transcript flushed later) or the assertion at "expect(h.host.sent).toEqual([])" fails because the comment already flushed on its own. Either way it is red.
|
||||
|
||||
- [ ] **Step 5: Bracket the bridge message handler with beginPending / endPending**
|
||||
|
||||
In `src/telegram/bridge.ts`, find the `bot.on("message", async (ctx) => { … })` handler (currently around line 110–130) and replace its body so it becomes:
|
||||
|
||||
```ts
|
||||
bot.on("message", async (ctx) => {
|
||||
const isCommand = ctx.message.entities?.some((e) => e.type === "bot_command" && e.offset === 0) ?? false;
|
||||
if (isCommand) {
|
||||
await ctx.reply("Unknown command.");
|
||||
return;
|
||||
}
|
||||
debouncer.beginPending();
|
||||
try {
|
||||
const result = await normalizeMessage(ctx.message, {
|
||||
download: (fileId) => downloadFile(bot, config.telegramBotToken, fileId, deps.fetchFn),
|
||||
transcriber: deps.transcriber,
|
||||
workspaceDir: config.workspaceDir,
|
||||
botUserId: bot.botInfo?.id,
|
||||
});
|
||||
for (const note of result.notes) await ctx.reply(note);
|
||||
if (result.transcript) await ctx.reply(`🎤 ${result.transcript}`);
|
||||
if (!result.input) {
|
||||
if (result.notes.length === 0) await ctx.reply("I can handle text, photos, voice notes, and files.");
|
||||
return;
|
||||
}
|
||||
if (host.busy) await ctx.react("👀").catch(() => undefined);
|
||||
debouncer.add(result.input);
|
||||
} finally {
|
||||
debouncer.endPending();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Note: the `beginPending()` call is **inside** the handler but **outside** the try, so a throw from `beginPending` itself (impossible in practice) would not leak a token, and the `endPending` is guaranteed by the `finally`. The command-early-return above `beginPending` is deliberate — commands do not go through the debouncer at all.
|
||||
|
||||
- [ ] **Step 6: Run the bridge tests and confirm the new test passes**
|
||||
|
||||
Run: `npm test -- src/telegram/bridge.test.ts`
|
||||
|
||||
Expected: all tests pass, including the new race test and the existing "merges a forward-with-comment pair" test (which uses sequential `await`s and therefore was never subject to the race — it still works because `beginPending` is released before the next handler starts).
|
||||
|
||||
- [ ] **Step 7: Run the full suite and typecheck**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
|
||||
Expected: everything green.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/telegram/bridge.ts src/telegram/bridge.test.ts
|
||||
git commit -m "Hold debouncer flush while a Telegram message handler is transcribing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Manual E2E entry in the README
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] **Step 1: Append item 20 to the manual end-to-end checklist**
|
||||
|
||||
Open `README.md` and append a new numbered item after the existing item 19 (currently the last item in the "Manual end-to-end checklist" section, line 107):
|
||||
|
||||
```
|
||||
20. Forward a voice message from another chat and add a text comment. The bot should reply once, referencing both your comment and what the voice said, not twice with each in isolation.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "Add manual E2E entry for forward-with-audio-comment merge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Task 1 covers the debouncer API (`InputDebouncer — pending-token API` section of the spec) and all seven debouncer unit test cases. Task 2 covers the `bridge.ts` wiring (`Bridge — bracket the message handler` section) and the end-to-end bridge test. Task 3 covers the README manual-E2E addition.
|
||||
- **Placeholder scan:** no TBDs, no "similar to Task N", every code step has a complete code block.
|
||||
- **Type consistency:** `beginPending()` / `endPending()` are the only new method names; both spellings are used identically in the debouncer, the bridge, and every test.
|
||||
- **Discarded-during-pending path** is covered by both the debouncer unit test (Task 1 Step 1, sixth new test) and by the fact that `discard()` is only called from the `/new` command handler, which is guarded by the `isCommand` early return in the bridge (so it can never race with a `beginPending()` in the same request).
|
||||
Reference in New Issue
Block a user