checkpoint

This commit is contained in:
Siddharth Ganesan
2026-08-13 14:34:20 -07:00
parent 733e5220f2
commit 2c8acbf7c6
5 changed files with 118 additions and 20 deletions
@@ -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,
})
)
@@ -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<boolean | null>(null)
const [expandedTakeoverId, setExpandedTakeoverId] = useState<string | null>(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}
/>
</div>
@@ -957,7 +957,6 @@ function MessageContentInner({
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
isCurrentSection={i === segments.length - 1}
isLaneOpen={segment.isOpen}
/>
</div>
@@ -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')
})
})
@@ -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
}