@@ -2746,8 +2746,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
27462746 expect . objectContaining ( { text : expect . stringMatching ( / 🛠 ️ E x e c < \/ b > $ / ) } ) ,
27472747 ) ;
27482748 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 3 , "Final answer" ) ;
2749- expect ( answerDraftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
2750- expect ( answerDraftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 2 ) ;
2749+ // The tool-progress window repositions before the final (deferred delete),
2750+ // never an immediate clear/delete.
2751+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
2752+ // The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2753+ // so that clear finds no live message id and never deletes the window.
2754+ if ( answerDraftStream . clear . mock . invocationCallOrder . length > 0 ) {
2755+ expect (
2756+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ,
2757+ ) . toBeLessThan ( answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ) ;
2758+ }
27512759 const progressResetOrder = answerDraftStream . forceNewMessage . mock . invocationCallOrder [ 0 ] ;
27522760 const progressUpdateOrder = answerDraftStream . updatePreview . mock . invocationCallOrder [ 0 ] ;
27532761 expect ( progressResetOrder ) . toBeLessThan ( progressUpdateOrder ) ;
@@ -2774,8 +2782,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
27742782 ) ;
27752783 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 2 , "Site B shows Y." ) ;
27762784 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 3 , "Final answer" ) ;
2777- expect ( answerDraftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 2 ) ;
2778- expect ( answerDraftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
2785+ // The tool-progress window repositions (deferred delete) rather than an
2786+ // immediate clear when the following text block takes over the lane.
2787+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
2788+ // The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2789+ // so that clear finds no live message id and never deletes the window.
2790+ if ( answerDraftStream . clear . mock . invocationCallOrder . length > 0 ) {
2791+ expect (
2792+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ,
2793+ ) . toBeLessThan ( answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ) ;
2794+ }
27792795 expect ( deliverReplies ) . not . toHaveBeenCalled ( ) ;
27802796 } ) ;
27812797
@@ -2815,12 +2831,20 @@ describe("dispatchTelegramMessage draft streaming", () => {
28152831 expect . objectContaining ( { text : expect . stringMatching ( / 🛠 ️ E x e c < \/ b > $ / ) } ) ,
28162832 ) ;
28172833 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 1 , "Branch is up to date" ) ;
2818- expect ( answerDraftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 1 ) ;
2819- expect ( answerDraftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
2820- const clearOrder = answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ;
2821- const rotationOrder = answerDraftStream . forceNewMessage . mock . invocationCallOrder [ 0 ] ;
2834+ // Reposition, not delete-then-repost: the tool-progress window is rewound
2835+ // for a new message and its delete deferred until after the replacement
2836+ // lands. clear() (immediate delete) must NOT run — that scroll-jumps.
2837+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
2838+ // The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2839+ // so that clear finds no live message id and never deletes the window.
2840+ if ( answerDraftStream . clear . mock . invocationCallOrder . length > 0 ) {
2841+ expect (
2842+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ,
2843+ ) . toBeLessThan ( answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ) ;
2844+ }
2845+ const rotationOrder =
2846+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ;
28222847 const finalUpdateOrder = answerDraftStream . update . mock . invocationCallOrder [ 0 ] ;
2823- expect ( clearOrder ) . toBeLessThan ( rotationOrder ) ;
28242848 expect ( rotationOrder ) . toBeLessThan ( finalUpdateOrder ) ;
28252849 } ) ;
28262850
@@ -2841,12 +2865,19 @@ describe("dispatchTelegramMessage draft streaming", () => {
28412865 expect . objectContaining ( { text : expect . stringMatching ( / 🛠 ️ E x e c < \/ b > $ / ) } ) ,
28422866 ) ;
28432867 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 1 , "Branch is up to date" ) ;
2844- expect ( answerDraftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 1 ) ;
2845- expect ( answerDraftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
2846- const clearOrder = answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ;
2847- const rotationOrder = answerDraftStream . forceNewMessage . mock . invocationCallOrder [ 0 ] ;
2868+ // Across an assistant boundary the tool-progress window still repositions
2869+ // (new message first, deferred delete) rather than deleting immediately.
2870+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
2871+ // The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2872+ // so that clear finds no live message id and never deletes the window.
2873+ if ( answerDraftStream . clear . mock . invocationCallOrder . length > 0 ) {
2874+ expect (
2875+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ,
2876+ ) . toBeLessThan ( answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ) ;
2877+ }
2878+ const rotationOrder =
2879+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ;
28482880 const finalUpdateOrder = answerDraftStream . update . mock . invocationCallOrder [ 0 ] ;
2849- expect ( clearOrder ) . toBeLessThan ( rotationOrder ) ;
28502881 expect ( rotationOrder ) . toBeLessThan ( finalUpdateOrder ) ;
28512882 } ) ;
28522883
@@ -2862,12 +2893,19 @@ describe("dispatchTelegramMessage draft streaming", () => {
28622893
28632894 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 1 , "🛠️ Exec: pnpm test" ) ;
28642895 expect ( answerDraftStream . update ) . toHaveBeenNthCalledWith ( 2 , "Tests passed" ) ;
2865- expect ( answerDraftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 1 ) ;
2866- expect ( answerDraftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
2867- const clearOrder = answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ;
2868- const rotationOrder = answerDraftStream . forceNewMessage . mock . invocationCallOrder [ 0 ] ;
2896+ // Verbose tool result window repositions before the final: new message
2897+ // first, superseded delete deferred (no immediate clear/delete).
2898+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
2899+ // The reposition rewinds the stream BEFORE any deliverer cleanup clear(),
2900+ // so that clear finds no live message id and never deletes the window.
2901+ if ( answerDraftStream . clear . mock . invocationCallOrder . length > 0 ) {
2902+ expect (
2903+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ,
2904+ ) . toBeLessThan ( answerDraftStream . clear . mock . invocationCallOrder [ 0 ] ) ;
2905+ }
2906+ const rotationOrder =
2907+ answerDraftStream . rotateToNewMessageDeferringDelete . mock . invocationCallOrder [ 0 ] ;
28692908 const finalUpdateOrder = answerDraftStream . update . mock . invocationCallOrder [ 1 ] ;
2870- expect ( clearOrder ) . toBeLessThan ( rotationOrder ) ;
28712909 expect ( rotationOrder ) . toBeLessThan ( finalUpdateOrder ) ;
28722910 } ) ;
28732911
@@ -3058,6 +3096,42 @@ describe("dispatchTelegramMessage draft streaming", () => {
30583096 expect ( texts ) . toContain ( "Done" ) ;
30593097 } ) ;
30603098
3099+ it ( "repositions the tool-progress window (deferred delete) when text follows durable reasoning" , async ( ) => {
3100+ // on-off mid-stream jump: a durable 🧠 posts BELOW the tool-progress window;
3101+ // when answer text then takes over the lane, the window must reposition
3102+ // (send-new-first, delete-old-deferred) rather than delete-then-repost,
3103+ // which scroll-jumps the Telegram client.
3104+ loadSessionStore . mockReturnValue ( { s1 : { reasoningLevel : "on" } } ) ;
3105+ const { answerDraftStream } = setupDraftStreams ( { answerMessageId : 2001 } ) ;
3106+ dispatchReplyWithBufferedBlockDispatcher . mockImplementation (
3107+ async ( { dispatcherOptions, replyOptions } ) => {
3108+ await replyOptions ?. onToolStart ?.( { name : "exec" , phase : "start" } ) ;
3109+ await dispatcherOptions . deliver (
3110+ { text : "<think>hidden</think>" , isReasoning : true } ,
3111+ { kind : "block" } ,
3112+ ) ;
3113+ // Answer text mid-turn takes the lane over from the tool-progress window.
3114+ await dispatcherOptions . deliver ( { text : "Here is the answer" } , { kind : "block" } ) ;
3115+ await dispatcherOptions . deliver ( { text : "Here is the answer." } , { kind : "final" } ) ;
3116+ return { queuedFinal : true } ;
3117+ } ,
3118+ ) ;
3119+
3120+ await dispatchWithContext ( {
3121+ context : createContext ( {
3122+ ctxPayload : { SessionKey : "s1" } as unknown as TelegramMessageContext [ "ctxPayload" ] ,
3123+ } ) ,
3124+ streamMode : "progress" ,
3125+ telegramCfg : { streaming : { mode : "progress" } } ,
3126+ } ) ;
3127+
3128+ // The tool-progress window was repositioned via the deferred-delete path,
3129+ // never an immediate clear() (which deletes the window above the durable 🧠
3130+ // and reposts below — the focus-jump).
3131+ expect ( answerDraftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalled ( ) ;
3132+ expect ( answerDraftStream . clear ) . not . toHaveBeenCalled ( ) ;
3133+ } ) ;
3134+
30613135 it ( "posts the collapse bar durably with no delete when the window has no live message" , async ( ) => {
30623136 // When finalizeToPreview cannot edit in place (no live window message id),
30633137 // the bar is still surfaced — as a durable post — and the window is NOT
@@ -3968,9 +4042,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
39684042 "<b>Shelling</b>\n<b>🔎 Web Search</b> <code>docs lookup</code>\n<b>Update</b> <code>tests passed</code>" ,
39694043 ) ,
39704044 ) ;
3971- expect ( draftStream . forceNewMessage ) . toHaveBeenCalledTimes ( 1 ) ;
39724045 expect ( draftStream . materialize ) . not . toHaveBeenCalled ( ) ;
3973- expect ( draftStream . clear ) . toHaveBeenCalledTimes ( 1 ) ;
4046+ // A tool-progress-only window with nothing to summarize is torn down via the
4047+ // deferred-delete reposition (new content first, delete later), not a bare
4048+ // immediate clear/delete or forceNewMessage.
4049+ expect ( draftStream . rotateToNewMessageDeferringDelete ) . toHaveBeenCalledTimes ( 1 ) ;
4050+ expect ( draftStream . forceNewMessage ) . not . toHaveBeenCalled ( ) ;
4051+ expect ( draftStream . clear ) . not . toHaveBeenCalled ( ) ;
39744052 expectDeliveredReply ( 0 , { text : "Final after tool" } ) ;
39754053 expect ( editMessageTelegram ) . not . toHaveBeenCalled ( ) ;
39764054 } ) ;
0 commit comments