Skip to content

Commit 1182e55

Browse files
Avoid duplicate finalized Telegram previews
1 parent f13b320 commit 1182e55

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

packages/pilegram/src/renderer.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,33 @@ import type { Writer } from "./writer.ts";
66
const route: Route = { chatId: 1, threadId: 0 };
77

88
/** A Writer stub that records provisional messages, edits, and final output. */
9-
function fakeWriter(opts?: { rejectHtml?: boolean }) {
9+
function fakeWriter(opts?: {
10+
rejectHtml?: boolean;
11+
rejectUnchanged?: boolean;
12+
}) {
1013
const sent: { text: string; html: boolean }[] = [];
1114
const edits: { id: number; text: string; html: boolean }[] = [];
1215
const deleted: number[] = [];
16+
const messages = new Map<number, string>();
17+
let nextId = 1;
1318
const writer = {
1419
async persist(text: string, extra?: { parse_mode?: string }) {
1520
const html = extra?.parse_mode === "HTML";
1621
if (html && opts?.rejectHtml)
1722
throw new Error("Bad Request: can't parse entities");
23+
const message_id = nextId++;
1824
sent.push({ text, html });
19-
return { message_id: sent.length };
25+
messages.set(message_id, text);
26+
return { message_id };
2027
},
2128
async editText(id: number, text: string, extra?: { parse_mode?: string }) {
2229
const html = extra?.parse_mode === "HTML";
2330
if (html && opts?.rejectHtml)
2431
throw new Error("Bad Request: can't parse entities");
32+
if (opts?.rejectUnchanged && messages.get(id) === text)
33+
throw new Error("Bad Request: message is not modified");
2534
edits.push({ id, text, html });
35+
messages.set(id, text);
2636
return { message_id: id };
2737
},
2838
async deleteMessage(id: number) {
@@ -52,6 +62,39 @@ test("finalizes as Telegram HTML", async () => {
5262
expect(edits[0]!.text).toContain("<b>world</b>");
5363
});
5464

65+
test("preserves a provisional message across continuation starts", async () => {
66+
const { sent, edits, writer } = fakeWriter();
67+
const r = new Renderer(writer, route);
68+
r.onAgentStart();
69+
r.onThinking("first attempt");
70+
await flush();
71+
r.onAgentStart(); // Pi continuation/retry, not a new user turn
72+
r.onThinking(" continues");
73+
r.onSettled("final answer");
74+
await flush();
75+
76+
expect(sent).toHaveLength(1);
77+
expect(edits).toHaveLength(1);
78+
expect(edits[0]).toMatchObject({ id: 1, text: "final answer", html: true });
79+
});
80+
81+
test("does not duplicate a final answer when the preview is already identical", async () => {
82+
const { sent, edits, writer } = fakeWriter({ rejectUnchanged: true });
83+
const finalized: { id: number; text: string }[] = [];
84+
const r = new Renderer(writer, route, (id, text) =>
85+
finalized.push({ id, text }),
86+
);
87+
r.onAgentStart();
88+
r.onText("same answer");
89+
await flush();
90+
r.onSettled("same answer");
91+
await flush();
92+
93+
expect(sent).toEqual([{ text: "same answer", html: false }]);
94+
expect(edits).toHaveLength(0);
95+
expect(finalized).toEqual([{ id: 1, text: "same answer" }]);
96+
});
97+
5598
test("falls back to plain text when Telegram rejects the HTML", async () => {
5699
const { sent, writer } = fakeWriter({ rejectHtml: true });
57100
const r = new Renderer(writer, route);

packages/pilegram/src/renderer.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export class Renderer {
7272
}
7373

7474
onAgentStart() {
75+
// Pi emits agent_start for low-level continuations/retries within one user
76+
// turn. Resetting here would orphan a live provisional Telegram message,
77+
// which then can neither be finalized nor deleted.
78+
if (this.running) {
79+
this.log.debug("agent continuation; preserving preview");
80+
return;
81+
}
7582
this.clearTimers();
7683
this.running = true;
7784
this.acc = "";
@@ -270,9 +277,12 @@ export class Renderer {
270277
const preview = (this.preview ??= { latestText: text });
271278
preview.latestText = text;
272279
if (preview.id !== undefined) {
273-
this.writer
274-
.editText(preview.id, text)
275-
.catch((e) => this.log.warn("preview update failed", errFields(e)));
280+
this.writer.editText(preview.id, text).catch((e) => {
281+
// Coalesced flushes can queue the same text twice. Telegram reports that
282+
// as a 400, but it already has the desired state.
283+
if (isMessageNotModified(e)) return;
284+
this.log.warn("preview update failed", errFields(e));
285+
});
276286
return;
277287
}
278288
if (preview.starting) return;
@@ -324,14 +334,23 @@ export class Renderer {
324334
await this.writer.editText(id, first, extra);
325335
this.onSent?.(id, first);
326336
} catch (e) {
327-
if (!extra) throw e;
328-
this.log.warn(
329-
"HTML preview finalize rejected; retrying as plain text",
330-
errFields(e),
331-
);
332-
const plain = htmlToPlain(first);
333-
await this.writer.editText(id, plain);
334-
this.onSent?.(id, plain);
337+
// The streamed preview can already equal the final answer. Telegram's
338+
// "message is not modified" is success for this state machine, not an
339+
// HTML error. Retrying it as plain text then falling back to persist()
340+
// created the duplicate message shown in the chat.
341+
if (isMessageNotModified(e)) {
342+
this.log.debug("preview already contains final text");
343+
this.onSent?.(id, first);
344+
} else {
345+
if (!extra) throw e;
346+
this.log.warn(
347+
"HTML preview finalize rejected; retrying as plain text",
348+
errFields(e),
349+
);
350+
const plain = htmlToPlain(first);
351+
await this.writer.editText(id, plain);
352+
this.onSent?.(id, plain);
353+
}
335354
}
336355
for (const chunk of chunks.slice(1)) this.sendFinal(chunk, extra);
337356
} catch (e) {
@@ -394,6 +413,12 @@ export class Renderer {
394413
}
395414
}
396415

416+
/** Telegram uses a 400 for a no-op edit; the requested state is already live. */
417+
function isMessageNotModified(e: unknown): boolean {
418+
const message = e instanceof Error ? e.message : String(e);
419+
return /message is not modified/i.test(message);
420+
}
421+
397422
/** Strip Telegram-HTML tags and unescape entities, for the plain-text fallback. */
398423
function htmlToPlain(s: string): string {
399424
return s

0 commit comments

Comments
 (0)