@@ -64,6 +64,8 @@ export interface SessionOptions {
6464
6565export 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