diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 4808893b4e..9f83098c06 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -110,7 +110,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -184,7 +183,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -206,7 +204,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 103e6ba6e4..070734ddf7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -37,8 +37,6 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean - /** This group is the latest section in its parent sequence (drives collapse). */ - isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean } @@ -110,7 +108,6 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, - isCurrentSection = false, isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -123,17 +120,18 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and any of: the lane is open (the subagent is - // actively running), this is the current/latest section, or there is unresolved - // work. A finished group stays open until the NEXT section starts (it is no - // longer the latest), instead of collapsing the instant its own work resolves. - // Keying "still running" off the lane-open signal (not `resolved` alone) avoids - // a collapse/reopen flicker on parallel siblings: a subagent's tools all - // momentarily read "done" in the gap between its last search and its `respond` - // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open - // lane bridges that gap so the row never collapses mid-run. The turn ending - // (isStreaming false) collapses everything; a manual toggle pins the choice. - const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) + // Expand while the turn is live and the subagent is still working: the lane + // is open, or there is unresolved work. When the lane closes and the work + // resolves the group collapses — with parallel subagents, finished siblings + // fold away while the still-running ones stay open, instead of every group + // lingering expanded until the next section starts. Keying "still running" + // off the lane-open signal (not `resolved` alone) avoids a collapse/reopen + // flicker mid-run: a subagent's tools all momentarily read "done" in the gap + // between its last search and its `respond` ("Gathering thoughts") tool, + // transiently flipping `resolved` true; the open lane bridges that gap. The + // turn ending (isStreaming false) collapses everything; a manual toggle pins + // the choice. + const autoExpanded = isStreaming && (isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -219,7 +217,6 @@ export function AgentGroup({ items={item.group.items} isDelegating={item.group.isDelegating} isStreaming={isStreaming} - isCurrentSection={idx === items.length - 1} isLaneOpen={item.group.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 24dfc3fd84..033ecf0cd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -957,7 +957,6 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} - isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 971ecd57b8..1c5eb2d72d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -556,3 +556,93 @@ describe('reduceEvent — span-start owner reconciliation', () => { expect((lane as AgentNode).agentId).toBe('workflow') }) }) + +describe('reduceEvent — span end settles stale lane tools', () => { + const laneScope = { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } as Scope + + it('marks still-running tools success when their lane ends cleanly', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + // No result for click-1 — dropped/reordered past the lane end. + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('success') + + const laneId = model.agentBySpanId.get('S1') + const lane = laneId ? model.nodes.get(laneId) : undefined + if (lane?.kind !== 'agent') throw new Error('expected agent lane') + expect(lane.status).toBe('success') + }) + + it('marks still-running tools error when the lane ends with an error', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: { error: 'boom' } }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + }) + + it('leaves settled tools alone and lets a late result overwrite the settle', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + // Late result arrives after the settle — it must win. + envelope( + 4, + 'tool', + { + phase: 'result', + toolCallId: 'click-1', + toolName: 'browser_click', + success: false, + error: 'nope', + }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + expect(click.result?.error).toBe('nope') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index d636bf8b47..25651a0eb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -611,10 +611,25 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (data?.pending === true) break breakLane(model, resolvedSpanId, tsMs) const node = model.nodes.get(resolvedSpanId) + const spanErrored = Boolean(data && asString(data.error)) if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) { - node.status = data && asString(data.error) ? 'error' : 'success' + node.status = spanErrored ? 'error' : 'success' node.endSeq = seq } + // The lane is over: settle any tool row still `running` in it (its + // result was dropped or reordered past the end). Left open, the row + // pins the whole group expanded and shimmering for the rest of the + // turn even though the subagent already returned. A late result event + // still corrects this — applyToolResult overwrites unconditionally. + for (const id of model.order) { + const stale = model.nodes.get(id) + if (stale?.kind === 'tool' && stale.spanId === resolvedSpanId) { + if (stale.status === 'running') { + stale.status = spanErrored ? 'error' : 'success' + stale.streamingArgs = undefined + } + } + } } break }