Skip to content

Commit 8cb63c8

Browse files
Preserve Telegram message order between turns
1 parent f11d785 commit 8cb63c8

2 files changed

Lines changed: 43 additions & 24 deletions

File tree

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: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export interface SessionOptions {
6464

6565
export class Session {
6666
private busy = false;
67+
/** Final Telegram writes from a settled turn; the next prompt waits for these. */
68+
private settling?: Promise<void>;
6769
private voiceMode = false;
6870
private spokeThisTurn = false; // set if the agent sent a voice note via tg_send_voice this turn
6971
private readonly unsubscribe: () => void;
@@ -186,22 +188,31 @@ export class Session {
186188
break;
187189
case "agent_settled": {
188190
const finalText = this.agent.getLastAssistantText();
189-
this.renderer.onSettled(finalText);
190-
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);
191+
// Do not let the next prompt start writing until this turn has claimed
192+
// its final Telegram writes. Otherwise a fast next turn can enqueue its
193+
// preview before this turn finishes replacing its preview.
194+
this.settling = (async () => {
195+
await this.renderer.onSettled(finalText);
196+
// A voice-only turn's text is spoken, never rendered to Telegram — don't
197+
// record it as the last-rendered answer, or reconcile would suppress the
198+
// legitimate text repost if we crash before the voice note is sent.
199+
this.onFinalized?.(this.voiceMode ? undefined : finalText);
200+
// Voice mode: speak the answer as a voice note — unless the agent already
201+
// sent one itself via tg_send_voice, which would double up.
202+
if (
203+
this.voiceMode &&
204+
this.voice &&
205+
!this.spokeThisTurn &&
206+
finalText &&
207+
finalText.trim() !== ""
208+
)
209+
await this.speak(finalText);
210+
})()
211+
.catch((e) => this.log.error("turn finalization failed", errFields(e)))
212+
.finally(() => {
213+
this.settling = undefined;
214+
this.busy = false;
215+
});
205216
break;
206217
}
207218
default:
@@ -220,14 +231,22 @@ export class Session {
220231
async handlePrompt(
221232
text: string,
222233
opts?: { images?: ImageContent[]; messageId?: number; speak?: boolean },
223-
) {
234+
): Promise<void> {
224235
const images = opts?.images;
225236
if (opts?.messageId !== undefined) {
226237
if (this.turn) this.turn.messageId = opts.messageId; // for tg_react
227238
this.messageLog?.add(opts.messageId, "user", text); // for the context-injected id table
228239
}
229240

230241
if (this.busy) {
242+
// `agent_settled` fires before its final preview edit has necessarily
243+
// reached Telegram. This is a completed turn, not steering: wait for its
244+
// writes, then start a fresh turn in chronological order.
245+
if (this.settling) {
246+
this.log.info("waiting for prior turn finalization");
247+
await this.settling;
248+
return this.handlePrompt(text, opts);
249+
}
231250
this.log.info("steering into running turn");
232251
await this.agent.steer(text, images);
233252
return;
@@ -244,7 +263,9 @@ export class Session {
244263
this.renderer.onError(e);
245264
})
246265
.finally(() => {
247-
this.busy = false;
266+
// agent_settled owns the transition to idle while final Telegram writes
267+
// are pending. For failures that never settle, release the session here.
268+
if (!this.settling) this.busy = false;
248269
});
249270
}
250271

0 commit comments

Comments
 (0)