@@ -23,6 +23,7 @@ import { join } from "node:path";
2323import type { MessageLog } from "./context.ts" ;
2424import { errFields , log as rootLog } from "./log.ts" ;
2525import type { ImageContent } from "./media.ts" ;
26+ import { SerialQueue } from "./queue.ts" ;
2627import { Renderer } from "./renderer.ts" ;
2728import type { Route } from "./route.ts" ;
2829import { routeKey } from "./route.ts" ;
@@ -64,6 +65,8 @@ export interface SessionOptions {
6465
6566export 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 }
0 commit comments