Skip to content

Commit 12d88a7

Browse files
Merge pull request #41 from ngalaiko/fix/telegram-message-order
Preserve Telegram message order between turns
2 parents d09d4b1 + 5509e20 commit 12d88a7

5 files changed

Lines changed: 86 additions & 31 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from "bun:test";
2+
import { SerialQueue } from "./queue.ts";
3+
4+
test("runs tasks in FIFO order after a failed task", async () => {
5+
const queue = new SerialQueue();
6+
const events: string[] = [];
7+
8+
const first = queue.enqueue(async () => {
9+
events.push("first");
10+
throw new Error("expected");
11+
});
12+
const second = queue.enqueue(async () => {
13+
events.push("second");
14+
});
15+
16+
await expect(first).rejects.toThrow("expected");
17+
await second;
18+
await queue.flush();
19+
expect(events).toEqual(["first", "second"]);
20+
});

packages/pilegram/src/queue.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* A failure-tolerant FIFO promise queue.
3+
*
4+
* Each submitted task starts after every earlier task has settled. Individual
5+
* task failures are returned to their caller but never poison later work.
6+
*/
7+
export class SerialQueue {
8+
private tail: Promise<void> = Promise.resolve();
9+
10+
enqueue<T>(task: () => Promise<T>): Promise<T> {
11+
const run = this.tail.then(task);
12+
this.tail = run.then(
13+
() => undefined,
14+
() => undefined,
15+
);
16+
return run;
17+
}
18+
19+
/** Wait for all work submitted before this call. */
20+
async flush(): Promise<void> {
21+
await this.tail;
22+
}
23+
}

packages/pilegram/src/renderer.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export class Renderer {
155155
}
156156

157157
/** Finalize on agent_settled. `finalText` is the authoritative answer. */
158-
onSettled(finalText: string | undefined) {
158+
onSettled(finalText: string | undefined): Promise<void> {
159159
const answer = finalText && finalText.trim() !== "" ? finalText : this.acc;
160160
const counts = new Map(this.toolCounts);
161161
const elapsedMs = this.turnStartAt ? Date.now() - this.turnStartAt : 0;
@@ -175,14 +175,12 @@ export class Renderer {
175175
if (this.voiceMode) {
176176
// The Session sends this answer as a voice note; don't leave a provisional
177177
// text message behind while it does so.
178-
void this.deletePreview(preview);
179178
this.log.info("finalize: voice-only (text not persisted)");
180-
return;
179+
return this.deletePreview(preview);
181180
}
182181
if (answer.trim() === "") {
183-
void this.deletePreview(preview);
184182
this.log.info("finalize: empty (preview deleted)");
185-
return;
183+
return this.deletePreview(preview);
186184
}
187185

188186
// Strip bidi-override / zero-width chars so a prompt-injected answer can't
@@ -209,7 +207,7 @@ export class Renderer {
209207
html: !!extra,
210208
preview: !!preview,
211209
});
212-
void this.finalizePreview(preview, chunks, extra);
210+
return this.finalizePreview(preview, chunks, extra);
213211
}
214212

215213
/**

packages/pilegram/src/session.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { join } from "node:path";
2323
import type { MessageLog } from "./context.ts";
2424
import { errFields, log as rootLog } from "./log.ts";
2525
import type { ImageContent } from "./media.ts";
26+
import { SerialQueue } from "./queue.ts";
2627
import { Renderer } from "./renderer.ts";
2728
import type { Route } from "./route.ts";
2829
import { routeKey } from "./route.ts";
@@ -64,6 +65,8 @@ export interface SessionOptions {
6465

6566
export class Session {
6667
private busy = false;
68+
/** FIFO barrier between completed turns and the next turn's Telegram writes. */
69+
private readonly finalizations = new SerialQueue();
6770
private voiceMode = false;
6871
private spokeThisTurn = false; // set if the agent sent a voice note via tg_send_voice this turn
6972
private readonly unsubscribe: () => void;
@@ -186,22 +189,33 @@ export class Session {
186189
break;
187190
case "agent_settled": {
188191
const finalText = this.agent.getLastAssistantText();
189-
this.renderer.onSettled(finalText);
192+
const voiceMode = this.voiceMode;
193+
const spokeThisTurn = this.spokeThisTurn;
190194
this.busy = false;
191-
// A voice-only turn's text is spoken, never rendered to Telegram — don't
192-
// record it as the last-rendered answer, or reconcile would suppress the
193-
// legitimate text repost if we crash before the voice note is sent.
194-
this.onFinalized?.(this.voiceMode ? undefined : finalText);
195-
// Voice mode: speak the answer as a voice note — unless the agent already
196-
// sent one itself via tg_send_voice, which would double up.
197-
if (
198-
this.voiceMode &&
199-
this.voice &&
200-
!this.spokeThisTurn &&
201-
finalText &&
202-
finalText.trim() !== ""
203-
)
204-
void this.speak(finalText);
195+
// Claim final Telegram writes in FIFO order before another turn starts.
196+
// A fast following prompt waits on this queue instead of overtaking this
197+
// turn's preview replacement or voice-note delivery.
198+
void this.finalizations
199+
.enqueue(async () => {
200+
await this.renderer.onSettled(finalText);
201+
// A voice-only turn's text is spoken, never rendered to Telegram — don't
202+
// record it as the last-rendered answer, or reconcile would suppress the
203+
// legitimate text repost if we crash before the voice note is sent.
204+
this.onFinalized?.(voiceMode ? undefined : finalText);
205+
// Voice mode: speak the answer as a voice note — unless the agent already
206+
// sent one itself via tg_send_voice, which would double up.
207+
if (
208+
voiceMode &&
209+
this.voice &&
210+
!spokeThisTurn &&
211+
finalText &&
212+
finalText.trim() !== ""
213+
)
214+
await this.speak(finalText);
215+
})
216+
.catch((e) =>
217+
this.log.error("turn finalization failed", errFields(e)),
218+
);
205219
break;
206220
}
207221
default:
@@ -220,7 +234,7 @@ export class Session {
220234
async handlePrompt(
221235
text: string,
222236
opts?: { images?: ImageContent[]; messageId?: number; speak?: boolean },
223-
) {
237+
): Promise<void> {
224238
const images = opts?.images;
225239
if (opts?.messageId !== undefined) {
226240
if (this.turn) this.turn.messageId = opts.messageId; // for tg_react
@@ -232,6 +246,9 @@ export class Session {
232246
await this.agent.steer(text, images);
233247
return;
234248
}
249+
// `agent_settled` precedes its final preview edit. Drain all finalization
250+
// work before this fresh turn can enqueue a draft or tool output.
251+
await this.finalizations.flush();
235252
this.busy = true;
236253
this.voiceMode = opts?.speak ?? false; // reply modality matches the input
237254
this.renderer.setVoiceMode(this.voiceMode);
@@ -244,6 +261,8 @@ export class Session {
244261
this.renderer.onError(e);
245262
})
246263
.finally(() => {
264+
// A normal turn becomes idle at agent_settled. For failures that never
265+
// settle, release the session here.
247266
this.busy = false;
248267
});
249268
}

packages/pilegram/src/writer.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from "grammy/types";
2424
import type { Route } from "./route.ts";
2525
import { errFields, type Fields, log as rootLog } from "./log.ts";
26+
import { SerialQueue } from "./queue.ts";
2627

2728
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
2829

@@ -32,7 +33,7 @@ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
3233
const MAX_429_RETRIES = 8;
3334

3435
export class Writer {
35-
private tail: Promise<unknown> = Promise.resolve();
36+
private readonly queue = new SerialQueue();
3637
private readonly log: ReturnType<typeof rootLog.child>;
3738

3839
constructor(
@@ -52,13 +53,7 @@ export class Writer {
5253

5354
/** Serialize an op onto the route's queue, retrying on 429. */
5455
private enqueue<T>(label: string, op: () => Promise<T>): Promise<T> {
55-
const run = this.tail.then(() => this.execWithRetry(label, op));
56-
// Keep the chain alive regardless of individual failures.
57-
this.tail = run.then(
58-
() => undefined,
59-
() => undefined,
60-
);
61-
return run;
56+
return this.queue.enqueue(() => this.execWithRetry(label, op));
6257
}
6358

6459
private async execWithRetry<T>(
@@ -219,7 +214,7 @@ export class Writer {
219214

220215
/** Resolve once everything enqueued so far has been sent (ordering barrier). */
221216
async flush(): Promise<void> {
222-
await this.tail.catch(() => {});
217+
await this.queue.flush();
223218
}
224219

225220
/** Send a chat action ("typing", "record_voice", …). Best-effort, not queued

0 commit comments

Comments
 (0)