Skip to content

Commit 9c4f3bc

Browse files
committed
Group Mothership tools by activity and show scoped resource names
1 parent 4e29240 commit 9c4f3bc

22 files changed

Lines changed: 1316 additions & 153 deletions

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

Lines changed: 164 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,14 @@ describe('AgentGroup main tool summary', () => {
317317

318318
afterEach(() => act(() => root.unmount()))
319319

320-
const render = (items: AgentGroupItem[], isStreaming = true) => {
320+
const render = (items: AgentGroupItem[], isStreaming = true, completedGroupCount = 0) => {
321321
act(() => {
322322
root.render(
323323
createElement(AgentGroup, {
324324
agentName: 'mothership',
325325
agentLabel: 'Sim',
326326
items,
327+
completedGroupCount,
327328
isStreaming,
328329
isLaneOpen: isStreaming,
329330
})
@@ -332,31 +333,187 @@ describe('AgentGroup main tool summary', () => {
332333
return container.querySelector<HTMLButtonElement>('button[aria-expanded]')!
333334
}
334335

336+
const call = (id: string, status: ToolCallStatus, activity?: string): AgentGroupItem => ({
337+
type: 'tool',
338+
data: {
339+
id,
340+
toolName: 'cli_blocks_get',
341+
displayTitle: `Reading ${id} configuration`,
342+
status,
343+
params: {
344+
args: ['blocks', 'get', id],
345+
...(activity
346+
? {
347+
activity: {
348+
id: activity,
349+
title: activity,
350+
completedTitle: activity
351+
.replace(/^Checking/, 'Checked')
352+
.replace(/^Testing/, 'Tested')
353+
.replace(/^Building/, 'Built'),
354+
},
355+
}
356+
: {}),
357+
},
358+
},
359+
})
360+
361+
it('preserves intent through omitted updates and completion, and only renames on an explicit update', () => {
362+
const first = call('Exa', 'success', 'Checking search input requirements')
363+
const second = call('Start', 'executing')
364+
const third = call('Function', 'executing')
365+
const header = render([first, second, third])
366+
expect(header.textContent).toBe('Checking search input requirements + 1')
367+
expect(header.querySelectorAll('svg')).toHaveLength(1)
368+
render([first, call('Start', 'success'), third])
369+
expect(header.textContent).toBe('Checking search input requirements')
370+
render([first, call('Start', 'success'), call('Function', 'success')], false)
371+
expect(header.textContent).toBe('Checked search input requirements')
372+
const update = call('API', 'executing', 'Testing both API workflows')
373+
render([first, second, update])
374+
expect(header.textContent).toBe('Testing both API workflows + 1')
375+
render([call('Next', 'executing')])
376+
expect(header.textContent).toBe('Reading Next configuration')
377+
})
378+
379+
it('summarizes a completed batch with the additional group count and a flat tool log', () => {
380+
const items = [
381+
call('Exa', 'success', 'Checking search inputs'),
382+
call('Start', 'success', 'Checking start inputs'),
383+
call('Function', 'success'),
384+
]
385+
const header = render(items, true, 2)
386+
expect(header.textContent).toBe('Checked start inputs + 1')
387+
expect(header.getAttribute('aria-expanded')).toBe('false')
388+
act(() => header.click())
389+
expect(header.getAttribute('aria-expanded')).toBe('true')
390+
expect(container.querySelectorAll('button[aria-expanded]')).toHaveLength(1)
391+
expect(container.textContent).not.toContain('Checked search inputs')
392+
})
393+
394+
it.each(['cancelled', 'interrupted', 'skipped', 'rejected'] as const)(
395+
'does not claim completion for %s activity calls',
396+
(status) => {
397+
const header = render([call('Start', status, 'Checking start inputs')], false)
398+
expect(header.textContent).toBe(
399+
status === 'rejected' ? 'Failed checking start inputs' : 'Stopped checking start inputs'
400+
)
401+
}
402+
)
403+
404+
it('does not attribute another activity failure to the representative completed activity', () => {
405+
const header = render(
406+
[
407+
call('Exa', 'error', 'Checking search inputs'),
408+
call('Start', 'success', 'Checking start inputs'),
409+
],
410+
false,
411+
2
412+
)
413+
expect(header.textContent).toBe('Checked start inputs + 1')
414+
render(
415+
[
416+
call('Exa', 'success', 'Checking search inputs'),
417+
call('Start', 'error', 'Checking start inputs'),
418+
],
419+
false,
420+
2
421+
)
422+
expect(header.textContent).toBe('Failed checking start inputs + 1')
423+
})
424+
425+
it('waits for a complete streamed activity string and retains it when later calls omit activity', () => {
426+
const streaming = (streamingArgs: string): AgentGroupItem => ({
427+
type: 'tool',
428+
data: {
429+
id: 'streaming',
430+
toolName: 'sim_cli',
431+
displayTitle: 'Running CLI command',
432+
status: 'executing',
433+
streamingArgs,
434+
},
435+
})
436+
const header = render([streaming('{"activity":{"id":"inputs","title":"Checking')])
437+
expect(header.textContent).toBe('Working…')
438+
render([
439+
streaming(
440+
'{"activity":{"id":"inputs","title":"Checking search inputs","completedTitle":"Checked search inputs"},"args":['
441+
),
442+
])
443+
expect(header.textContent).toBe('Checking search inputs')
444+
render([
445+
streaming(
446+
'{"activity":{"id":"inputs","title":"Checking search inputs","completedTitle":"Checked search inputs"},"args":['
447+
),
448+
call('Exa', 'executing'),
449+
])
450+
expect(header.textContent).toBe('Checking search inputs + 1')
451+
})
452+
453+
it('uses current concrete calls and counts down across nested lanes when activity is absent', () => {
454+
const header = render([
455+
call('Exa', 'executing'),
456+
group([call('Start', 'executing'), call('Function', 'executing')]),
457+
])
458+
expect(header.textContent).toBe('Reading Function configuration + 2')
459+
render([
460+
call('Exa', 'executing'),
461+
group([call('Start', 'executing'), call('Function', 'success')]),
462+
])
463+
expect(header.textContent).toBe('Reading Start configuration + 1')
464+
render([
465+
call('Exa', 'executing'),
466+
group([call('Start', 'success'), call('Function', 'success')]),
467+
])
468+
expect(header.textContent).toBe('Reading Exa configuration')
469+
})
470+
471+
it('does not let a nested agent rename the parent or treat a resource title as intent', () => {
472+
const header = render([
473+
call('Exa', 'executing', 'Checking search inputs'),
474+
group([call('Start', 'executing', 'Building an unrelated API')]),
475+
])
476+
expect(header.textContent).toBe('Checking search inputs + 1')
477+
render([
478+
{
479+
type: 'tool',
480+
data: {
481+
id: 'file',
482+
toolName: 'cli_files_create',
483+
displayTitle: 'Creating Report',
484+
status: 'executing',
485+
params: { title: 'Report' },
486+
},
487+
},
488+
])
489+
expect(header.textContent).toBe('Creating Report')
490+
})
491+
335492
it('starts collapsed while streaming, shows the tool and count, and preserves manual expansion', () => {
336493
const items = [tool('success'), tool('executing')]
337494
const header = render(items)
338-
expect(header.textContent).toBe('Searching + 1')
495+
expect(header.textContent).toBe('Searching')
339496
expect(header.textContent).not.toContain('Sim')
340497
expect(header.getAttribute('aria-expanded')).toBe('false')
341498
act(() => header.click())
342499
expect(header.getAttribute('aria-expanded')).toBe('true')
343500
render([...items, tool('executing')])
344-
expect(header.textContent).toBe('Searching + 2')
501+
expect(header.textContent).toBe('Searching + 1')
345502
expect(header.getAttribute('aria-expanded')).toBe('true')
346503
act(() => header.click())
347504
render([...items, tool('executing')])
348505
expect(header.getAttribute('aria-expanded')).toBe('false')
349506
})
350507

351508
it.each(['success', 'error', 'cancelled'] as const)(
352-
'shows an honest terminal label for %s without losing the collapsed history count',
509+
'shows an honest terminal label for %s with no remaining-call count',
353510
(status) => {
354511
const header = render([tool('success'), tool(status)], false)
355512
expect(header.textContent).toBe(
356513
{
357-
success: 'Searched + 1',
358-
error: 'Failed searching + 1',
359-
cancelled: 'Stopped searching + 1',
514+
success: 'Searched',
515+
error: 'Failed searching',
516+
cancelled: 'Stopped searching',
360517
}[status]
361518
)
362519
expect(header.getAttribute('aria-expanded')).toBe('false')

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

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
'use client'
22

33
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4-
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText, Wrench } from '@sim/emcn'
4+
import { ChevronDown, cn, Expandable, ExpandableContent, OverflowText } from '@sim/emcn'
55
import { ShimmerText } from '@/components/ui'
66
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
7+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
78
import { Terminal as TerminalTool } from '@/lib/mothership/generated/tool-catalog-v1'
89
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
10+
import { readToolActivity } from '@/lib/mothership/tools/tool-activity'
911
import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display'
1012
import { useSmoothText } from '@/hooks/use-smooth-text'
1113
import { type ToolCallData, ToolCallStatus } from '../../../../types'
@@ -35,6 +37,8 @@ export type AgentGroupItem =
3537
| { type: 'agent_group'; group: NestedAgentGroup }
3638

3739
interface AgentGroupProps {
40+
activity?: ToolActivity
41+
completedGroupCount?: number
3842
error?: string
3943
agentName: string
4044
agentLabel: string
@@ -46,11 +50,21 @@ interface AgentGroupProps {
4650
}
4751

4852
function toolStatusTitle(tool: ToolCallData): string {
49-
// Raw tool names must never surface — derive a human title when no display
50-
// title was resolved upstream.
5153
return tool.displayTitle || getToolDisplayTitle(String(tool.toolName ?? ''), tool.params)
5254
}
5355

56+
/** Only explicit intent updates rename a main group; nested agents own their own headings. */
57+
function groupActivityTitle(items: AgentGroupItem[]): ToolActivity | undefined {
58+
for (let index = items.length - 1; index >= 0; index--) {
59+
const item = items[index]
60+
if (item.type !== 'tool') continue
61+
const tool = item.data
62+
const activity = readToolActivity(tool.params, tool.streamingArgs)
63+
if (activity?.title && activity.completedTitle) return activity
64+
}
65+
return undefined
66+
}
67+
5468
/**
5569
* Every tool in a group, in stream order, including those run by nested
5670
* agents. A parent's status line speaks for the whole subtree it delegated,
@@ -142,6 +156,8 @@ export function AgentGroup({
142156
isDelegating = false,
143157
isStreaming = false,
144158
isLaneOpen = false,
159+
activity: groupActivity,
160+
completedGroupCount = 0,
145161
error,
146162
}: AgentGroupProps) {
147163
const isMainAgent = agentName === 'mothership'
@@ -156,17 +172,51 @@ export function AgentGroup({
156172
)
157173
: tools.at(-1)
158174
if (!latest) return undefined
175+
const activity = isMainAgent ? (groupActivity ?? groupActivityTitle(items)) : undefined
176+
const initialCall = tools[0]
177+
const generatingFirstCall =
178+
tools.length === 1 &&
179+
initialCall?.status === ToolCallStatus.executing &&
180+
(initialCall.toolName === 'sim_cli' || initialCall.toolName === 'run_code') &&
181+
Object.keys(initialCall.params ?? {}).length === 0
182+
const activityTools =
183+
completedGroupCount > 1 && activity
184+
? tools.filter(
185+
(tool) => readToolActivity(tool.params, tool.streamingArgs)?.id === activity.id
186+
)
187+
: tools
188+
const failed = activityTools.find(
189+
(tool) => tool.status !== ToolCallStatus.success && isToolDone(tool.status)
190+
)
191+
const activityComplete = !failed && isAgentGroupResolved(items)
192+
const mainTitle =
193+
activity?.title && activity.completedTitle
194+
? activityComplete
195+
? activity.completedTitle
196+
: activity.title
197+
: generatingFirstCall
198+
? 'Working…'
199+
: toolStatusTitle(latest)
159200
return {
160-
toolName: latest.toolName,
161201
title: isMainAgent
162-
? getToolStatusDisplayTitle(toolStatusTitle(latest), latest.status, latest.toolName)
202+
? getToolStatusDisplayTitle(
203+
mainTitle,
204+
activity
205+
? running.length > 0
206+
? 'executing'
207+
: failed
208+
? failed.status === 'error' || failed.status === 'rejected'
209+
? 'error'
210+
: 'cancelled'
211+
: 'executing'
212+
: latest.status,
213+
latest.toolName
214+
)
163215
: toolStatusTitle(latest),
164-
additionalCount: isMainAgent ? tools.length - 1 : Math.max(0, running.length - 1),
216+
additionalCount: Math.max(0, (isMainAgent ? completedGroupCount : 0) - 1, running.length - 1),
165217
}
166-
}, [isLaneOpen, isMainAgent, items])
167-
const AgentIcon = isMainAgent
168-
? getAgentIcon(status?.toolName ?? '', Wrench)
169-
: getAgentIcon(agentName)
218+
}, [isLaneOpen, isMainAgent, items, groupActivity, completedGroupCount])
219+
const AgentIcon = getAgentIcon(agentName)
170220
const headerText = error
171221
? isMainAgent
172222
? 'Tool call failed'
@@ -183,7 +233,8 @@ export function AgentGroup({
183233
browserAgentAvailable && isLaneOpen ? getActiveBrowserTakeover(items) : null
184234
const nestedBrowserTakeover = browserAgentAvailable && hasNestedBrowserTakeover(items)
185235
const isWorking =
186-
!activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen))
236+
!activeBrowserTakeover &&
237+
((isDelegating && !resolved) || (isStreaming && isLaneOpen && (!isMainAgent || !resolved)))
187238

188239
/** Keep every log collapsed until opened, except for blocking user interactions. */
189240
const [manualExpanded, setManualExpanded] = useState(false)
@@ -213,9 +264,11 @@ export function AgentGroup({
213264
aria-expanded={expanded}
214265
className='group/agent flex w-full min-w-0 cursor-pointer items-center gap-2 text-left'
215266
>
216-
<div className='flex size-[16px] shrink-0 items-center justify-center'>
217-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
218-
</div>
267+
{!isMainAgent && (
268+
<div className='flex size-[16px] shrink-0 items-center justify-center'>
269+
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
270+
</div>
271+
)}
219272
{isWorking ? (
220273
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
221274
) : (
@@ -236,9 +289,11 @@ export function AgentGroup({
236289
</button>
237290
) : (
238291
<div className='flex min-w-0 items-center gap-2'>
239-
<div className='flex size-[16px] shrink-0 items-center justify-center'>
240-
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
241-
</div>
292+
{!isMainAgent && (
293+
<div className='flex size-[16px] shrink-0 items-center justify-center'>
294+
<AgentIcon className='size-[16px] text-[var(--text-icon)]' />
295+
</div>
296+
)}
242297
{isWorking ? (
243298
<ShimmerText className='min-w-0 truncate text-sm'>{headerText}</ShimmerText>
244299
) : (

0 commit comments

Comments
 (0)