@@ -9,12 +9,13 @@ import { withTestRenderer } from "./harness"
99import { attachSessionBridge , createRecordingPort } from "./runtime-bridge"
1010import { createAppShell } from "./shell"
1111import {
12+ isCollapsibleRow ,
1213 paintStreamRow ,
1314 toolSentenceLines ,
1415 type RowLayout ,
1516 type StreamRow ,
1617} from "./stream"
17- import { pushToolCall , pushToolResult } from "./tool-rows"
18+ import { pendingCallIndex , pushToolCall , pushToolResult } from "./tool-rows"
1819
1920const LAYOUT : RowLayout = { width : 72 , multiAgent : false }
2021
@@ -124,6 +125,91 @@ describe("a run of identical calls", () => {
124125 } )
125126} )
126127
128+ describe ( "parallel calls to the same tool" , ( ) => {
129+ // CL-5562: three `task` calls dispatched in one turn all carry
130+ // meta === "task" — name alone cannot tell them apart, so a result must
131+ // find its own row by call id or it resolves whichever pending "task" row
132+ // happens to be newest, leaving the others stranded pending forever and
133+ // turning any later same-name result into an orphaned extra row.
134+ test ( "each result resolves its own call by id, not the newest pending call of that name" , ( ) => {
135+ const rows : StreamRow [ ] = [ ]
136+ pushToolCall ( rows , {
137+ name : "task" ,
138+ arguments : JSON . stringify ( { agent : "intern" , description : "Fix CL-5559 heading shake" } ) ,
139+ callId : "c1" ,
140+ } )
141+ pushToolCall ( rows , {
142+ name : "task" ,
143+ arguments : JSON . stringify ( { agent : "intern" , description : "Fix CL-5560 approval UI" } ) ,
144+ callId : "c2" ,
145+ } )
146+ pushToolCall ( rows , {
147+ name : "task" ,
148+ arguments : JSON . stringify ( { agent : "intern" , description : "Fix CL-5561 scroll/history" } ) ,
149+ callId : "c3" ,
150+ } )
151+ expect ( rows . length ) . toBe ( 3 )
152+
153+ // Results land out of dispatch order, as real sub-agent completion does.
154+ pushToolResult ( rows , { name : "task" , content : "done c2" , callId : "c2" } )
155+ pushToolResult ( rows , { name : "task" , content : "done c1" , callId : "c1" } )
156+ pushToolResult ( rows , { name : "task" , content : "done c3" , callId : "c3" } )
157+
158+ expect ( rows . length ) . toBe ( 3 )
159+ expect ( rows . every ( ( r ) => r . pending !== true ) ) . toBe ( true )
160+ expect ( rows . every ( ( r ) => r . failed !== true ) ) . toBe ( true )
161+ expect ( rows [ 0 ] ?. summary ) . toBe ( "Fix CL-5559 heading shake" )
162+ expect ( rows [ 0 ] ?. text ) . toBe ( "done c1" )
163+ expect ( rows [ 1 ] ?. summary ) . toBe ( "Fix CL-5560 approval UI" )
164+ expect ( rows [ 1 ] ?. text ) . toBe ( "done c2" )
165+ expect ( rows [ 2 ] ?. summary ) . toBe ( "Fix CL-5561 scroll/history" )
166+ expect ( rows [ 2 ] ?. text ) . toBe ( "done c3" )
167+ } )
168+
169+ // A miss must not fall back to "the newest pending row of that name" — that
170+ // fallback is exactly the LIFO misattribution this test file exists to rule
171+ // out, and every live caller (the bridge's own call map, subagent session
172+ // entries, resumed history with ids) always carries a real id, so a miss
173+ // here means the id genuinely does not belong to anything on the log.
174+ test ( "an id that matches nothing on the log answers nothing, not the newest pending call" , ( ) => {
175+ const rows : StreamRow [ ] = [
176+ { role : "tool" , text : "" , meta : "task" , pending : true , callId : "a1" } ,
177+ { role : "tool" , text : "" , meta : "task" , pending : true , callId : "b1" } ,
178+ ]
179+ expect ( pendingCallIndex ( rows , "task" , "zzz-does-not-exist" ) ) . toBe ( - 1 )
180+
181+ pushToolResult ( rows , { name : "task" , content : "orphan" , callId : "zzz-does-not-exist" } )
182+ // Answers nothing on the log — appended as its own row rather than
183+ // resolving (and thereby corrupting) an unrelated in-flight call.
184+ expect ( rows . length ) . toBe ( 3 )
185+ expect ( rows [ 0 ] ?. pending ) . toBe ( true )
186+ expect ( rows [ 1 ] ?. pending ) . toBe ( true )
187+ } )
188+
189+ // Acceptance criterion: a failed sub-agent surfaces its error inline
190+ // (expandable), not a bare mark with nothing behind it. `mergeToolRows` /
191+ // `toolResultRow` already carry the failed result's own text into `detail`
192+ // — untouched by this fix, but only reachable per-call once results resolve
193+ // to the right row instead of a neighbour's.
194+ test ( "a failed call keeps its error text behind the expand arrow" , ( ) => {
195+ const rows : StreamRow [ ] = [ ]
196+ pushToolCall ( rows , {
197+ name : "task" ,
198+ arguments : JSON . stringify ( { agent : "intern" , description : "Fix CL-5559 heading shake" } ) ,
199+ callId : "c1" ,
200+ } )
201+ pushToolResult ( rows , {
202+ name : "task" ,
203+ content : 'Error: sub-agent "Fix CL-5559 heading shake" failed: boom' ,
204+ isError : true ,
205+ callId : "c1" ,
206+ } )
207+ expect ( rows [ 0 ] ?. failed ) . toBe ( true )
208+ expect ( isCollapsibleRow ( rows [ 0 ] ! ) ) . toBe ( true )
209+ expect ( rows [ 0 ] ?. detail ?. [ 0 ] ?. [ 0 ] ?. text ) . toContain ( "boom" )
210+ } )
211+ } )
212+
127213describe ( "a long subject" , ( ) => {
128214 test ( "is cut to one line rather than wrapped" , ( ) => {
129215 const row = toolCallRow ( {
0 commit comments