Skip to content

Commit e4e5d59

Browse files
committed
Bubble nested agents' tool calls into the parent's status line
The collapsed status only scanned a group's OWN tool items and skipped nested agent groups, so a parent that had delegated froze on its last own tool while its child did the actual work — the line described nothing that was running. Status now walks the whole subtree: any tool at any depth counts, the most recently started running one is shown, and the rest become the same '+ n' overflow. With nothing running it falls back to the last tool at any depth, so an idle parent still reflects where its subtree got to.
1 parent 7c58fe1 commit e4e5d59

2 files changed

Lines changed: 112 additions & 18 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,77 @@ describe('AgentGroup browser takeover', () => {
229229
act(() => root.unmount())
230230
})
231231
})
232+
233+
describe('AgentGroup nested status line', () => {
234+
let container: HTMLDivElement
235+
let root: Root
236+
237+
beforeEach(() => {
238+
container = document.createElement('div')
239+
document.body.appendChild(container)
240+
root = createRoot(container)
241+
})
242+
243+
afterEach(() => {
244+
act(() => root.unmount())
245+
container.remove()
246+
})
247+
248+
const namedTool = (
249+
displayTitle: string,
250+
status: ToolCallStatus,
251+
startedAt?: number
252+
): AgentGroupItem => ({
253+
type: 'tool',
254+
data: {
255+
id: `${displayTitle}-${startedAt ?? 0}`,
256+
toolName: 'grep',
257+
displayTitle,
258+
status,
259+
startedAt,
260+
},
261+
})
262+
263+
const render = (items: AgentGroupItem[]) => {
264+
act(() => {
265+
root.render(
266+
createElement(AgentGroup, {
267+
agentName: 'workflow',
268+
agentLabel: 'Workflow Agent',
269+
items,
270+
isStreaming: true,
271+
isLaneOpen: true,
272+
})
273+
)
274+
})
275+
return container.textContent ?? ''
276+
}
277+
278+
it("shows a nested agent's running tool instead of the parent's finished one", () => {
279+
const header = render([
280+
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
281+
group([namedTool('Deploying Invoice Sync as API', 'executing' as ToolCallStatus, 2)]),
282+
])
283+
expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
284+
})
285+
286+
it('counts running tools across depths with the + n suffix', () => {
287+
const header = render([
288+
namedTool('Reading workflow', 'executing' as ToolCallStatus, 1),
289+
group([
290+
namedTool('Deploying Invoice Sync as API', 'executing' as ToolCallStatus, 3),
291+
namedTool('Checking deployment status', 'executing' as ToolCallStatus, 2),
292+
]),
293+
])
294+
// Latest start wins; the other two running become the overflow count.
295+
expect(header).toContain('Deploying Invoice Sync as API + 2')
296+
})
297+
298+
it('falls back to the last tool at any depth when nothing is running', () => {
299+
const header = render([
300+
namedTool('Reading workflow', 'success' as ToolCallStatus, 1),
301+
group([namedTool('Deploying Invoice Sync as API', 'success' as ToolCallStatus, 2)]),
302+
])
303+
expect(header).toContain('Workflow Agent — Deploying Invoice Sync as API')
304+
})
305+
})

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
44
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
55
import { ShimmerText } from '@/components/ui'
66
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
@@ -43,6 +43,27 @@ interface AgentGroupProps {
4343
isLaneOpen?: boolean
4444
}
4545

46+
function toolStatusTitle(tool: ToolCallData): string {
47+
return tool.displayTitle || String(tool.toolName ?? '')
48+
}
49+
50+
/**
51+
* Every tool in a group, in stream order, including those run by nested
52+
* agents. A parent's status line speaks for the whole subtree it delegated,
53+
* so a grandchild's work is what surfaces while the parent itself waits.
54+
*/
55+
function collectGroupTools(items: AgentGroupItem[]): ToolCallData[] {
56+
const tools: ToolCallData[] = []
57+
const walk = (list: AgentGroupItem[]) => {
58+
for (const item of list) {
59+
if (item.type === 'tool') tools.push(item.data)
60+
else if (item.type === 'agent_group') walk(item.group.items)
61+
}
62+
}
63+
walk(items)
64+
return tools
65+
}
66+
4667
/** True when any row in this group (or a nested one) is waiting on a permission decision. */
4768
function hasAwaitingApproval(items: AgentGroupItem[]): boolean {
4869
return items.some((item) => {
@@ -117,27 +138,26 @@ export function AgentGroup({
117138
const isMainAgent = agentName === 'mothership'
118139
// Collapsed status line: the latest tool call, always in its RUNNING
119140
// phrasing — it never flips to the completed rewrite (that lives in the
120-
// expanded log). With parallel tools, the most recently started
121-
// still-running one wins, with a +N for its running siblings; between
141+
// expanded log). Work delegated further down bubbles up, so a group whose
142+
// own turn is idle still narrates what its nested agent is doing rather
143+
// than freezing on its last own tool. With several tools running at any
144+
// depth, the most recently started wins and the rest become "+ n"; between
122145
// rounds the last tool's title stays frozen; a closed lane shows the bare
123146
// name.
124-
const status = (() => {
147+
const status = useMemo(() => {
125148
if (isMainAgent || !isLaneOpen) return undefined
126-
let running: string | undefined
127-
let runningCount = 0
128-
let lastAny: string | undefined
129-
for (const it of items) {
130-
if (it.type !== 'tool') continue
131-
const title = it.data.displayTitle || String(it.data.toolName ?? '')
132-
lastAny = title
133-
if (it.data.status === ToolCallStatus.executing) {
134-
running = title
135-
runningCount += 1
136-
}
149+
const tools = collectGroupTools(items)
150+
const running = tools.filter((tool) => tool.status === ToolCallStatus.executing)
151+
if (running.length > 0) {
152+
const latest = running.reduce((newest, tool) =>
153+
(tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
154+
)
155+
const title = toolStatusTitle(latest)
156+
return running.length > 1 ? `${title} + ${running.length - 1}` : title
137157
}
138-
if (running) return runningCount > 1 ? `${running} + ${runningCount - 1}` : running
139-
return lastAny
140-
})()
158+
const last = tools.at(-1)
159+
return last ? toolStatusTitle(last) : undefined
160+
}, [isLaneOpen, isMainAgent, items])
141161
const headerText = status ? `${agentLabel}${status}` : agentLabel
142162
const hasItems = items.length > 0
143163
const resolved = isAgentGroupResolved(items)

0 commit comments

Comments
 (0)