@@ -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,8 +65,8 @@ export interface SessionOptions {
6465
6566export class Session {
6667 private busy = false ;
67- /** Final Telegram writes from a settled turn; the next prompt waits for these . */
68- private settling ?: Promise < void > ;
68+ /** FIFO barrier between completed turns and the next turn's Telegram writes . */
69+ private readonly finalizations = new SerialQueue ( ) ;
6970 private voiceMode = false ;
7071 private spokeThisTurn = false ; // set if the agent sent a voice note via tg_send_voice this turn
7172 private readonly unsubscribe : ( ) => void ;
@@ -188,31 +189,31 @@ export class Session {
188189 break ;
189190 case "agent_settled" : {
190191 const finalText = this . agent . getLastAssistantText ( ) ;
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- } ) ;
192+ const voiceMode = this . voiceMode ;
193+ const spokeThisTurn = this . spokeThisTurn ;
194+ this . busy = false ;
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 ) => this . log . error ( "turn finalization failed" , errFields ( e ) ) ) ;
216217 break ;
217218 }
218219 default :
@@ -239,18 +240,13 @@ export class Session {
239240 }
240241
241242 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- }
250243 this . log . info ( "steering into running turn" ) ;
251244 await this . agent . steer ( text , images ) ;
252245 return ;
253246 }
247+ // `agent_settled` precedes its final preview edit. Drain all finalization
248+ // work before this fresh turn can enqueue a draft or tool output.
249+ await this . finalizations . flush ( ) ;
254250 this . busy = true ;
255251 this . voiceMode = opts ?. speak ?? false ; // reply modality matches the input
256252 this . renderer . setVoiceMode ( this . voiceMode ) ;
@@ -263,9 +259,9 @@ export class Session {
263259 this . renderer . onError ( e ) ;
264260 } )
265261 . finally ( ( ) => {
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 ;
262+ // A normal turn becomes idle at agent_settled. For failures that never
263+ // settle, release the session here.
264+ this . busy = false ;
269265 } ) ;
270266 }
271267
0 commit comments