@@ -189,6 +189,377 @@ describe('run() text emission', () => {
189189 } )
190190 } )
191191
192+ test ( 'emits aggregated text blocks while streaming chunk deltas' , async ( ) => {
193+ const events : PrintModeEvent [ ] = [ ]
194+ const streamChunks : string [ ] = [ ]
195+ const runPromise = run ( {
196+ ...baseRunOptions ,
197+ handleEvent : ( event ) => {
198+ events . push ( event )
199+ } ,
200+ handleStreamChunk : ( chunk ) => {
201+ streamChunks . push ( chunk )
202+ } ,
203+ } )
204+
205+ const handler = await waitForHandler ( )
206+
207+ await handler . options . onResponseChunk (
208+ responseChunk ( handler , {
209+ type : 'text' ,
210+ text : 'Hello ' ,
211+ } ) ,
212+ )
213+ await handler . options . onResponseChunk (
214+ responseChunk ( handler , {
215+ type : 'text' ,
216+ text : 'Hello world' ,
217+ } ) ,
218+ )
219+ await handler . options . onResponseChunk (
220+ responseChunk ( handler , {
221+ type : 'finish' ,
222+ totalCost : 0 ,
223+ } ) ,
224+ )
225+
226+ await resolvePrompt ( handler )
227+ await runPromise
228+
229+ expect ( streamChunks ) . toEqual ( [ 'Hello ' , 'world' ] )
230+
231+ const textEvents = events . filter (
232+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
233+ event . type === 'text' ,
234+ )
235+ expect ( textEvents ) . toEqual ( [
236+ expect . objectContaining ( { type : 'text' , text : 'Hello world' } ) ,
237+ ] )
238+ } )
239+
240+ test ( 'emits combined text when raw string and structured chunks interleave' , async ( ) => {
241+ const events : PrintModeEvent [ ] = [ ]
242+ const streamChunks : string [ ] = [ ]
243+ const runPromise = run ( {
244+ ...baseRunOptions ,
245+ handleEvent : ( event ) => {
246+ events . push ( event )
247+ } ,
248+ handleStreamChunk : ( chunk ) => {
249+ streamChunks . push ( chunk )
250+ } ,
251+ } )
252+
253+ const handler = await waitForHandler ( )
254+
255+ await handler . options . onResponseChunk (
256+ responseChunk ( handler , 'Root string ' ) ,
257+ )
258+ await handler . options . onResponseChunk (
259+ responseChunk ( handler , {
260+ type : 'text' ,
261+ text : 'section complete' ,
262+ } ) ,
263+ )
264+ await handler . options . onResponseChunk (
265+ responseChunk ( handler , {
266+ type : 'finish' ,
267+ totalCost : 0 ,
268+ } ) ,
269+ )
270+
271+ await resolvePrompt ( handler )
272+ await runPromise
273+
274+ expect ( streamChunks ) . toEqual ( [ 'Root string ' , 'section complete' ] )
275+
276+ const textEvents = events . filter (
277+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
278+ event . type === 'text' ,
279+ )
280+
281+ expect ( textEvents ) . toEqual ( [
282+ expect . objectContaining ( {
283+ type : 'text' ,
284+ text : 'Root string section complete' ,
285+ } ) ,
286+ ] )
287+ } )
288+
289+ test ( 'keeps earlier text when new fragments are shorter than accumulated text' , async ( ) => {
290+ const events : PrintModeEvent [ ] = [ ]
291+ const runPromise = run ( {
292+ ...baseRunOptions ,
293+ handleEvent : async ( event ) => {
294+ events . push ( event )
295+ } ,
296+ } )
297+
298+ const handler = await waitForHandler ( )
299+
300+ await handler . options . onResponseChunk (
301+ responseChunk ( handler , {
302+ type : 'text' ,
303+ text : 'Intro line ' ,
304+ } ) ,
305+ )
306+ await handler . options . onResponseChunk (
307+ responseChunk ( handler , {
308+ type : 'text' ,
309+ text : 'continues' ,
310+ } ) ,
311+ )
312+ await handler . options . onResponseChunk (
313+ responseChunk ( handler , {
314+ type : 'text' ,
315+ text : ' and ends.<codebuff_tool_call>' ,
316+ } ) ,
317+ )
318+ await handler . options . onResponseChunk (
319+ responseChunk ( handler , {
320+ type : 'tool_call' ,
321+ toolCallId : 'tool-aggregate' ,
322+ toolName : 'example_tool' ,
323+ input : { } ,
324+ } ) ,
325+ )
326+ await handler . options . onResponseChunk (
327+ responseChunk ( handler , {
328+ type : 'finish' ,
329+ totalCost : 0 ,
330+ } ) ,
331+ )
332+
333+ await resolvePrompt ( handler )
334+ await runPromise
335+
336+ const textEvents = events . filter (
337+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
338+ event . type === 'text' ,
339+ )
340+
341+ expect ( textEvents ) . toEqual ( [
342+ expect . objectContaining ( {
343+ type : 'text' ,
344+ text : 'Intro line continues and ends.' ,
345+ } ) ,
346+ ] )
347+ } )
348+
349+ test ( 'flushes subagent text on subagent finish' , async ( ) => {
350+ const events : PrintModeEvent [ ] = [ ]
351+ const runPromise = run ( {
352+ ...baseRunOptions ,
353+ handleEvent : async ( event ) => {
354+ events . push ( event )
355+ } ,
356+ } )
357+
358+ const handler = await waitForHandler ( )
359+
360+ await handler . options . onResponseChunk (
361+ responseChunk ( handler , {
362+ type : 'text' ,
363+ agentId : 'agent-sub' ,
364+ text : 'Subagent output block' ,
365+ } ) ,
366+ )
367+ await handler . options . onResponseChunk (
368+ responseChunk ( handler , {
369+ type : 'subagent_finish' ,
370+ agentId : 'agent-sub' ,
371+ agentType : 'helper' ,
372+ displayName : 'Helper' ,
373+ onlyChild : false ,
374+ } ) ,
375+ )
376+ await handler . options . onResponseChunk (
377+ responseChunk ( handler , {
378+ type : 'finish' ,
379+ totalCost : 0 ,
380+ } ) ,
381+ )
382+
383+ await resolvePrompt ( handler )
384+ await runPromise
385+
386+ const textEvents = events . filter (
387+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
388+ event . type === 'text' ,
389+ )
390+
391+ expect ( textEvents ) . toContainEqual (
392+ expect . objectContaining ( {
393+ type : 'text' ,
394+ agentId : 'agent-sub' ,
395+ text : 'Subagent output block' ,
396+ } ) ,
397+ )
398+ } )
399+
400+ test ( 'handles tool XML that spans multiple text chunks' , async ( ) => {
401+ const events : PrintModeEvent [ ] = [ ]
402+ const runPromise = run ( {
403+ ...baseRunOptions ,
404+ handleEvent : ( event ) => {
405+ events . push ( event )
406+ } ,
407+ } )
408+
409+ const handler = await waitForHandler ( )
410+
411+ await handler . options . onResponseChunk (
412+ responseChunk ( handler , {
413+ type : 'text' ,
414+ text : 'Before <codebuff_tool_call>{"x":1}' ,
415+ } ) ,
416+ )
417+ await handler . options . onResponseChunk (
418+ responseChunk ( handler , {
419+ type : 'text' ,
420+ text : '</codebuff_tool_call> after' ,
421+ } ) ,
422+ )
423+ await handler . options . onResponseChunk (
424+ responseChunk ( handler , {
425+ type : 'finish' ,
426+ totalCost : 0 ,
427+ } ) ,
428+ )
429+
430+ await resolvePrompt ( handler )
431+ await runPromise
432+
433+ const textEvents = events . filter (
434+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
435+ event . type === 'text' ,
436+ )
437+
438+ expect ( textEvents ) . toEqual ( [
439+ expect . objectContaining ( { text : 'Before' } ) ,
440+ expect . objectContaining ( { text : 'after' } ) ,
441+ ] )
442+ } )
443+
444+ test ( 'trims surrounding newlines before emitting text' , async ( ) => {
445+ const events : PrintModeEvent [ ] = [ ]
446+ const runPromise = run ( {
447+ ...baseRunOptions ,
448+ handleEvent : ( event ) => {
449+ events . push ( event )
450+ } ,
451+ } )
452+
453+ const handler = await waitForHandler ( )
454+
455+ await handler . options . onResponseChunk (
456+ responseChunk ( handler , {
457+ type : 'text' ,
458+ text : '\nLine 1\nLine 2\n\n' ,
459+ } ) ,
460+ )
461+ await handler . options . onResponseChunk (
462+ responseChunk ( handler , {
463+ type : 'finish' ,
464+ totalCost : 0 ,
465+ } ) ,
466+ )
467+
468+ await resolvePrompt ( handler )
469+ await runPromise
470+
471+ const textEvents = events . filter (
472+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
473+ event . type === 'text' ,
474+ )
475+
476+ expect ( textEvents ) . toEqual ( [
477+ expect . objectContaining ( {
478+ text : 'Line 1\nLine 2' ,
479+ } ) ,
480+ ] )
481+ } )
482+
483+ test ( 'skips whitespace-only sections' , async ( ) => {
484+ const events : PrintModeEvent [ ] = [ ]
485+ const runPromise = run ( {
486+ ...baseRunOptions ,
487+ handleEvent : ( event ) => {
488+ events . push ( event )
489+ } ,
490+ } )
491+
492+ const handler = await waitForHandler ( )
493+
494+ await handler . options . onResponseChunk (
495+ responseChunk ( handler , {
496+ type : 'text' ,
497+ text : '\n\n' ,
498+ } ) ,
499+ )
500+ await handler . options . onResponseChunk (
501+ responseChunk ( handler , {
502+ type : 'finish' ,
503+ totalCost : 0 ,
504+ } ) ,
505+ )
506+
507+ await resolvePrompt ( handler )
508+ await runPromise
509+
510+ const textEvents = events . filter (
511+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
512+ event . type === 'text' ,
513+ )
514+
515+ expect ( textEvents ) . toEqual ( [ ] )
516+ } )
517+
518+ test ( 'flushes buffered text when finish clears residual tool XML state' , async ( ) => {
519+ const events : PrintModeEvent [ ] = [ ]
520+ const streamChunks : string [ ] = [ ]
521+ const runPromise = run ( {
522+ ...baseRunOptions ,
523+ handleEvent : ( event ) => {
524+ events . push ( event )
525+ } ,
526+ handleStreamChunk : ( chunk ) => {
527+ streamChunks . push ( chunk )
528+ } ,
529+ } )
530+
531+ const handler = await waitForHandler ( )
532+
533+ await handler . options . onResponseChunk (
534+ responseChunk ( handler , 'Streaming start ' ) ,
535+ )
536+ await handler . options . onResponseChunk (
537+ responseChunk ( handler , 'continues before <codebuff_tool_call' ) ,
538+ )
539+ await handler . options . onResponseChunk (
540+ responseChunk ( handler , {
541+ type : 'finish' ,
542+ totalCost : 0 ,
543+ } ) ,
544+ )
545+
546+ await resolvePrompt ( handler )
547+ await runPromise
548+
549+ const textEvents = events . filter (
550+ ( event ) : event is PrintModeEvent & { type : 'text' } =>
551+ event . type === 'text' ,
552+ )
553+
554+ expect ( streamChunks ) . toEqual ( [ 'Streaming start ' , 'continu' ] )
555+
556+ expect ( textEvents ) . toEqual ( [
557+ expect . objectContaining ( {
558+ text : 'Streaming start continu' ,
559+ } ) ,
560+ ] )
561+ } )
562+
192563 test ( 'splits root sections around tool events without duplication' , async ( ) => {
193564 const events : PrintModeEvent [ ] = [ ]
194565 const runPromise = run ( {
@@ -335,9 +706,6 @@ describe('run() text emission', () => {
335706 event . type === 'text' ,
336707 )
337708
338- expect ( textEvents . map ( ( event ) => event . text ) ) . toEqual ( [
339- 'Before ' ,
340- ' after' ,
341- ] )
709+ expect ( textEvents . map ( ( event ) => event . text ) ) . toEqual ( [ 'Before' , 'after' ] )
342710 } )
343711} )
0 commit comments