@@ -218,20 +218,31 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
218218 if ( tc . params ) state . toolArgsMap . set ( tc . id , tc . params )
219219 }
220220 }
221+ // Rebuild ALL open subagent lanes (not just the most recent one) so that a
222+ // reconnect mid-flight with multiple concurrent subagents rehydrates every
223+ // lane. A lane is closed when its `subagent` start block has an endedAt OR a
224+ // matching `subagent_end` marker exists (the live path stamps endedAt and
225+ // pushes subagent_end; the persisted backend path stamps endedAt only).
226+ const endedSpanIds = new Set < string > ( )
227+ const endedParents = new Set < string > ( )
221228 for ( const block of state . blocks ) {
222- if ( block . type === 'subagent' && block . spanId && block . content ) {
223- state . subagentBySpanId . set ( block . spanId , block . content )
229+ if ( block . type === 'subagent_end' ) {
230+ if ( block . spanId ) endedSpanIds . add ( block . spanId )
231+ if ( block . parentToolCallId ) endedParents . add ( block . parentToolCallId )
224232 }
225233 }
226- for ( let i = state . blocks . length - 1 ; i >= 0 ; i -- ) {
227- if ( state . blocks [ i ] . type === 'subagent' && state . blocks [ i ] . content ) {
228- state . activeSubagent = state . blocks [ i ] . content
229- state . activeSubagentParentToolCallId = state . blocks [ i ] . parentToolCallId
230- break
231- }
232- if ( state . blocks [ i ] . type === 'subagent_end' ) {
233- break
234+ for ( const block of state . blocks ) {
235+ if ( block . type !== 'subagent' || ! block . content || block . endedAt !== undefined ) continue
236+ if ( block . spanId && endedSpanIds . has ( block . spanId ) ) continue
237+ if ( block . parentToolCallId && endedParents . has ( block . parentToolCallId ) ) continue
238+ if ( block . spanId ) state . subagentBySpanId . set ( block . spanId , block . content )
239+ if ( block . parentToolCallId ) {
240+ state . subagentByParentToolCallId . set ( block . parentToolCallId , block . content )
234241 }
242+ // Keep a best-effort single pointer for legacy (no-spanId) dedup only;
243+ // routing no longer depends on it.
244+ state . activeSubagent = block . content
245+ state . activeSubagentParentToolCallId = block . parentToolCallId
235246 }
236247 } else if ( ! isStale ( ) ) {
237248 deps . streamingContentRef . current = ''
@@ -247,7 +258,15 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
247258 }
248259
249260 const stampBlockEnd = ( block : ContentBlock | undefined , ts ?: string ) => {
250- if ( block && block . endedAt === undefined ) block . endedAt = toEventMs ( ts )
261+ // Never stamp a subagent header here. Its endedAt is the renderer's
262+ // "group closed" signal (parseBlocksWithSpanTree), set explicitly only when
263+ // the subagent's span actually ends (the span-end handler and the backend
264+ // both set it directly). Stamping it as a generic block boundary — when the
265+ // next sibling subagent starts, or when this lane's first content arrives —
266+ // would close + prune concurrent subagents mid-stream, making them all flash
267+ // in, vanish to one, then reappear one-by-one as content trickles in.
268+ if ( ! block || block . type === 'subagent' ) return
269+ if ( block . endedAt === undefined ) block . endedAt = toEventMs ( ts )
251270 }
252271
253272 const ensureTextBlock = (
@@ -306,6 +325,12 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
306325 parentToolCallId : string | undefined ,
307326 spanId ?: string
308327 ) : string | undefined => {
328+ // Scope-only: resolve by the event's own identity. The legacy
329+ // `state.activeSubagent` fallback was removed — with concurrent subagents it
330+ // points at whichever started most recently and would mis-attribute an
331+ // interleaved event from a different lane. Well-formed subagent events carry
332+ // agentId (and spanId), so this resolves deterministically; anything else is
333+ // treated as main-lane rather than guessed.
309334 if ( agentId ) return agentId
310335 if ( spanId ) {
311336 const scoped = state . subagentBySpanId . get ( spanId )
@@ -315,20 +340,18 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext
315340 const scoped = state . subagentByParentToolCallId . get ( parentToolCallId )
316341 if ( scoped ) return scoped
317342 }
318- return state . activeSubagent
343+ return undefined
319344 }
320345
321346 const resolveParentForSubagentBlock = (
322347 subagent : string | undefined ,
323348 scopedParent : string | undefined
324349 ) : string | undefined => {
350+ // Scope-only: a subagent block's parent comes from the event's own scope.
351+ // The previous "first parent whose name matches" scan was ambiguous when two
352+ // concurrent subagents share an agent name, so it was removed.
325353 if ( ! subagent ) return undefined
326- if ( scopedParent ) return scopedParent
327- if ( state . activeSubagent === subagent ) return state . activeSubagentParentToolCallId
328- for ( const [ parent , name ] of state . subagentByParentToolCallId ) {
329- if ( name === subagent ) return parent
330- }
331- return undefined
354+ return scopedParent
332355 }
333356
334357 const flush = ( ) => {
0 commit comments