@@ -111,6 +111,101 @@ function toolResult(
111111}
112112
113113describe ( 'Slack tool progress' , ( ) => {
114+ it ( 'flushes a batched sentence before starting tool progress' , async ( ) => {
115+ vi . spyOn ( Date , 'now' ) . mockReturnValue ( 1000 )
116+ try {
117+ const { stream } = setup ( )
118+ await stream . start ( )
119+ await stream . onEvent ( {
120+ type : 'text' ,
121+ payload : { channel : 'assistant' , text : "I'll search " } ,
122+ } )
123+ await stream . onEvent ( {
124+ type : 'text' ,
125+ payload : { channel : 'assistant' , text : 'the connected sources for the handbook.' } ,
126+ } )
127+ await stream . onEvent ( toolCall ( 'search_workspace' ) )
128+ const chunks = api . append . mock . calls . flatMap ( ( call ) => call [ 3 ] )
129+ expect ( chunks ) . toEqual ( [
130+ { type : 'markdown_text' , text : "I'll search " } ,
131+ {
132+ type : 'markdown_text' ,
133+ text : 'the connected sources for the handbook.\n\n' ,
134+ } ,
135+ {
136+ type : 'task_update' ,
137+ id : expect . any ( String ) ,
138+ title : 'Searching documents…' ,
139+ status : 'in_progress' ,
140+ } ,
141+ ] )
142+ } finally {
143+ vi . restoreAllMocks ( )
144+ }
145+ } )
146+
147+ it ( 'keeps text contiguous across preparatory and hidden tool events' , async ( ) => {
148+ const { stream } = setup ( )
149+ await stream . start ( )
150+ await stream . onEvent ( {
151+ type : 'text' ,
152+ payload : { channel : 'assistant' , text : "I'll search" } ,
153+ } )
154+ for ( const attributes of [
155+ { partial : true } ,
156+ { status : 'generating' as const } ,
157+ { ui : { hidden : true } } ,
158+ { ui : { internal : true } } ,
159+ ] ) {
160+ const event = toolCall ( 'search_workspace' )
161+ await stream . onEvent ( { ...event , payload : { ...event . payload , ...attributes } } )
162+ }
163+ await stream . onEvent ( {
164+ type : 'text' ,
165+ payload : { channel : 'assistant' , text : ' the connected sources.' } ,
166+ } )
167+ await stream . finish ( result )
168+ expect ( deliveredText ( ) ) . toBe ( "I'll search the connected sources." )
169+ expect (
170+ api . append . mock . calls
171+ . flatMap ( ( call ) => call [ 3 ] )
172+ . every ( ( chunk ) => chunk . type === 'markdown_text' )
173+ ) . toBe ( true )
174+ } )
175+
176+ it ( 'serializes concurrent text and tool events without duplicating buffered text' , async ( ) => {
177+ const { stream } = setup ( )
178+ await stream . start ( )
179+ let releaseAppend ! : ( ) => void
180+ api . append . mockImplementationOnce (
181+ ( ) =>
182+ new Promise < void > ( ( resolve ) => {
183+ releaseAppend = resolve
184+ } )
185+ )
186+ const text = stream . onEvent ( {
187+ type : 'text' ,
188+ payload : { channel : 'assistant' , text : "I'll search the connected sources. " } ,
189+ } )
190+ await vi . waitFor ( ( ) => expect ( api . append ) . toHaveBeenCalledOnce ( ) , { interval : 1 } )
191+ const call = stream . onEvent ( toolCall ( 'search_workspace' ) )
192+ const completed = stream . onEvent ( toolResult ( 'search_workspace' ) )
193+ const finished = stream . finish ( result )
194+ expect ( api . append ) . toHaveBeenCalledOnce ( )
195+ expect ( api . stop ) . not . toHaveBeenCalled ( )
196+ releaseAppend ( )
197+ await Promise . all ( [ text , call , completed , finished ] )
198+ const chunks = api . append . mock . calls . flatMap ( ( call ) => call [ 3 ] )
199+ expect ( deliveredText ( ) ) . toBe ( "I'll search the connected sources. \n\n" )
200+ expect ( chunks . map ( ( chunk ) => chunk . type ) ) . toEqual ( [
201+ 'markdown_text' ,
202+ 'markdown_text' ,
203+ 'task_update' ,
204+ 'task_update' ,
205+ ] )
206+ expect ( chunks [ 3 ] ) . toEqual ( { ...chunks [ 2 ] , status : 'complete' } )
207+ } )
208+
114209 it . each ( [
115210 [ 'list_integrations' , 'Listing connected integrations…' ] ,
116211 [ 'search_workspace' , 'Searching documents…' ] ,
0 commit comments