@@ -58,6 +58,135 @@ function mainToolCall(id: string, name: string): ContentBlock {
5858 return { type : 'tool_call' , toolCall : { id, name, status : 'success' } , timestamp : 1 }
5959}
6060
61+ describe ( 'top-level activity groups' , ( ) => {
62+ const activityCall = ( id : string , title ?: string , spanId ?: string ) : ContentBlock => ( {
63+ type : 'tool_call' ,
64+ spanId,
65+ toolCall : {
66+ id,
67+ name : 'sim_cli' ,
68+ status : 'executing' ,
69+ params : title
70+ ? {
71+ activity : { id : title , title, completedTitle : title . replace ( 'Checking' , 'Checked' ) } ,
72+ }
73+ : { } ,
74+ } ,
75+ } )
76+ const activityReference = ( id : string , activityId : string , spanId ?: string ) : ContentBlock => ( {
77+ type : 'tool_call' ,
78+ spanId,
79+ toolCall : {
80+ id,
81+ name : 'sim_cli' ,
82+ status : 'executing' ,
83+ params : { activity : { id : activityId } } ,
84+ } ,
85+ } )
86+
87+ it . each ( [ undefined , 'main' ] ) (
88+ 'keeps parallel activities separate and rejoins their original group (%s)' ,
89+ ( spanId ) => {
90+ const blocks = [
91+ activityCall ( 'a1' , 'Checking invoice inputs' , spanId ) ,
92+ activityReference ( 'a2' , 'Checking invoice inputs' , spanId ) ,
93+ activityCall ( 'b1' , 'Checking customer inputs' , spanId ) ,
94+ activityReference ( 'a3' , 'Checking invoice inputs' , spanId ) ,
95+ activityReference ( 'a4' , 'Checking invoice inputs' , spanId ) ,
96+ ]
97+ const groups = parseBlocks ( blocks ) . filter ( ( segment ) => segment . type === 'agent_group' )
98+ expect ( groups ) . toHaveLength ( 2 )
99+ expect (
100+ groups . map ( ( group ) =>
101+ group . items . flatMap ( ( item ) => ( item . type === 'tool' ? [ item . data . id ] : [ ] ) )
102+ )
103+ ) . toEqual ( [ [ 'a1' , 'a2' , 'a3' , 'a4' ] , [ 'b1' ] ] )
104+ const completed = blocks . map (
105+ ( block ) : ContentBlock => ( {
106+ ...block ,
107+ toolCall : block . toolCall ? { ...block . toolCall , status : 'success' } : undefined ,
108+ } )
109+ )
110+ const stillRunning = [ ...completed . slice ( 0 , - 1 ) , blocks . at ( - 1 ) ! ]
111+ expect ( parseBlocks ( stillRunning ) ) . toHaveLength ( 2 )
112+ const merged = parseBlocks ( completed )
113+ expect ( merged ) . toHaveLength ( 1 )
114+ expect ( merged [ 0 ] ) . toMatchObject ( {
115+ id : groups [ 0 ] . id ,
116+ completedGroupCount : 2 ,
117+ activity : { completedTitle : 'Checked customer inputs' } ,
118+ items : blocks . map ( ( block ) => ( { type : 'tool' , data : { id : block . toolCall ! . id } } ) ) ,
119+ } )
120+ }
121+ )
122+
123+ it ( 'collapses completed contiguous batches independently across text and reused ids' , ( ) => {
124+ const blocks = [
125+ activityCall ( 'a1' , 'Checking invoice inputs' ) ,
126+ activityCall ( 'b1' , 'Checking customer inputs' ) ,
127+ mainText ( 'Now checking the updated inputs.' ) ,
128+ activityReference ( 'a2' , 'Checking invoice inputs' ) ,
129+ activityReference ( 'b2' , 'Checking customer inputs' ) ,
130+ ] . map (
131+ ( block ) : ContentBlock => ( {
132+ ...block ,
133+ toolCall : block . toolCall ? { ...block . toolCall , status : 'success' } : undefined ,
134+ } )
135+ )
136+ const segments = parseBlocks ( blocks )
137+ expect ( segments . map ( ( segment ) => segment . type ) ) . toEqual ( [ 'agent_group' , 'text' , 'agent_group' ] )
138+ const groups = segments . filter ( ( segment ) => segment . type === 'agent_group' )
139+ expect ( groups [ 0 ] . id ) . not . toBe ( groups [ 1 ] . id )
140+ expect ( groups . map ( ( group ) => group . completedGroupCount ) ) . toEqual ( [ 2 , 2 ] )
141+ expect ( groups . map ( ( group ) => group . activity ?. completedTitle ) ) . toEqual ( [
142+ 'Checked customer inputs' ,
143+ 'Checked customer inputs' ,
144+ ] )
145+ expect (
146+ groups . map ( ( group ) => group . items . map ( ( item ) => item . type === 'tool' && item . data . id ) )
147+ ) . toEqual ( [
148+ [ 'a1' , 'b1' ] ,
149+ [ 'a2' , 'b2' ] ,
150+ ] )
151+ } )
152+
153+ it ( 'keeps unassociated calls in a concrete fallback group instead of assigning another activity' , ( ) => {
154+ const groups = parseBlocks ( [
155+ activityCall ( 'a1' , 'Checking invoice inputs' ) ,
156+ activityCall ( 'b1' ) ,
157+ activityReference ( 'a2' , 'Checking invoice inputs' ) ,
158+ ] ) . filter ( ( segment ) => segment . type === 'agent_group' )
159+ expect ( groups ) . toHaveLength ( 2 )
160+ expect ( groups [ 1 ] . activity ) . toBeUndefined ( )
161+ expect ( groups [ 1 ] . items ) . toMatchObject ( [ { type : 'tool' , data : { id : 'b1' } } ] )
162+ } )
163+
164+ it ( 'does not merge activities across prose or absorb a subagent into the main activity' , ( ) => {
165+ const segments = parseBlocks ( [
166+ activityCall ( 'a1' , 'Checking invoice inputs' ) ,
167+ mainText ( 'The invoice inputs are valid.' ) ,
168+ activityReference ( 'a2' , 'Checking invoice inputs' ) ,
169+ subagentStart ( 'general' , 'child' , 'main' ) ,
170+ {
171+ ...activityCall ( 'child-tool' , 'Checking customer inputs' , 'child' ) ,
172+ toolCall : {
173+ ...activityCall ( 'child-tool' , 'Checking customer inputs' ) . toolCall ! ,
174+ calledBy : 'general' ,
175+ } ,
176+ } ,
177+ ] )
178+ expect ( segments . map ( ( segment ) => segment . type ) ) . toEqual ( [
179+ 'agent_group' ,
180+ 'text' ,
181+ 'agent_group' ,
182+ 'agent_group' ,
183+ ] )
184+ expect (
185+ segments . filter ( ( segment ) => segment . type === 'agent_group' ) . map ( ( group ) => group . agentName )
186+ ) . toEqual ( [ 'mothership' , 'mothership' , 'general' ] )
187+ } )
188+ } )
189+
61190function representativeToolArgs ( entry : ToolCatalogEntry ) : Record < string , unknown > {
62191 const args : Record < string , unknown > = { }
63192 if ( ! entry . parameters || typeof entry . parameters !== 'object' ) return args
0 commit comments