Skip to content

Commit 31231ba

Browse files
committed
Group Mothership tools by activity and show scoped resource names
1 parent 501091b commit 31231ba

25 files changed

Lines changed: 1196 additions & 143 deletions

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

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

33
import { type ComponentType, type ReactNode, useState } from 'react'
4+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
45
import { ThinkingLoader } from '@/components/ui/thinking-loader'
56
import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport'
67
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
@@ -51,6 +52,8 @@ export type AgentGroupItem =
5152
| { type: 'agent_group'; group: NestedAgentGroup }
5253

5354
export interface AgentGroupProps {
55+
activity?: ToolActivity
56+
completedGroupCount?: number
5457
error?: string
5558
agentName: string
5659
agentLabel: string
@@ -143,6 +146,8 @@ interface AgentGroupViewProps extends AgentGroupProps {
143146
export function AgentGroupView({
144147
agentName,
145148
agentLabel,
149+
activity: groupActivity,
150+
completedGroupCount,
146151
error,
147152
items,
148153
isDelegating = false,
@@ -239,6 +244,8 @@ export function AgentGroupView({
239244

240245
const activity = isMainAgent ? (
241246
<MainAgentActivity
247+
activity={groupActivity}
248+
completedGroupCount={completedGroupCount}
242249
items={items}
243250
ToolCallComponent={ToolCallComponent}
244251
renderItem={renderItem}

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

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,18 @@ describe('AgentGroup inline main activity', () => {
197197

198198
it('preserves manual expansion when more main-agent tools arrive', () => {
199199
const items = [tool('success'), tool('executing')]
200-
const render = (nextItems: AgentGroupItem[]) => act(() => {
201-
root.render(createElement(AgentGroup, {
202-
agentName: 'mothership', agentLabel: 'Sim', items: nextItems,
203-
isStreaming: true, isLaneOpen: true,
204-
}))
205-
})
200+
const render = (nextItems: AgentGroupItem[]) =>
201+
act(() => {
202+
root.render(
203+
createElement(AgentGroup, {
204+
agentName: 'mothership',
205+
agentLabel: 'Sim',
206+
items: nextItems,
207+
isStreaming: true,
208+
isLaneOpen: true,
209+
})
210+
)
211+
})
206212
render(items)
207213
const header = () => container.querySelector<HTMLElement>('[role="button"][aria-expanded]')
208214
expect(header()?.getAttribute('aria-expanded')).toBe('false')
@@ -215,6 +221,53 @@ describe('AgentGroup inline main activity', () => {
215221
expect(header()?.getAttribute('aria-expanded')).toBe('false')
216222
})
217223

224+
it.each([
225+
['success', 'Checked search requirements'],
226+
['error', 'Failed checking search requirements'],
227+
['cancelled', 'Stopped checking search requirements'],
228+
] as const)('uses an honest grouped activity label after %s', (status, expected) => {
229+
const item = tool(status)
230+
act(() =>
231+
root.render(
232+
createElement(AgentGroup, {
233+
agentName: 'mothership',
234+
agentLabel: 'Sim',
235+
activity: {
236+
id: 'search',
237+
title: 'Checking search requirements',
238+
completedTitle: 'Checked search requirements',
239+
},
240+
items: [item],
241+
isStreaming: true,
242+
isLaneOpen: true,
243+
})
244+
)
245+
)
246+
expect(container.textContent).toBe(expected)
247+
expect(container.querySelector('[class*="shimmer"]')).toBeNull()
248+
})
249+
250+
it('keeps the concrete running call distinct from grouped activity intent', () => {
251+
act(() =>
252+
root.render(
253+
createElement(AgentGroup, {
254+
agentName: 'mothership',
255+
agentLabel: 'Sim',
256+
activity: {
257+
id: 'search',
258+
title: 'Checking requirements',
259+
completedTitle: 'Checked requirements',
260+
},
261+
items: [tool('executing')],
262+
isStreaming: true,
263+
isLaneOpen: true,
264+
})
265+
)
266+
)
267+
expect(container.textContent).toBe('Searching')
268+
expect(container.textContent).not.toContain('Checking requirements')
269+
})
270+
218271
it('paces the active status in place and expands the full completed history', () => {
219272
vi.useFakeTimers()
220273
const first: AgentGroupItem = {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
12
import { type ComponentType, Fragment, type ReactNode } from 'react'
2-
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools'
3+
import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools'
34
import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view'
45
import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group'
56
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
67
import { needsToolInput } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
78
import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
89

910
interface MainAgentActivityProps {
11+
activity?: ToolActivity
12+
completedGroupCount?: number
1013
items: AgentGroupItem[]
1114
ToolCallComponent: ComponentType<ToolCallItemProps>
1215
renderItem: (item: AgentGroupItem, index: number) => ReactNode
@@ -24,6 +27,8 @@ function isStandaloneItem(item: AgentGroupItem): boolean {
2427
}
2528

2629
export function MainAgentActivity({
30+
activity: groupActivity,
31+
completedGroupCount,
2732
items,
2833
ToolCallComponent,
2934
renderItem,
@@ -38,6 +43,8 @@ export function MainAgentActivity({
3843
<ToolActivityGroup
3944
key={tools[0].id}
4045
tools={tools}
46+
activity={groupActivity}
47+
completedGroupCount={completedGroupCount}
4148
isActive={active}
4249
ToolCallComponent={ToolCallComponent}
4350
autoScrollActivity={autoScrollActivity}

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

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

3+
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
34
import { type ComponentType, Fragment, useState } from 'react'
45
import { ActivityStatus } from '@/components/ui/activity-status'
5-
import { getToolActivitySummaryActions } from '@/lib/copilot/tools/tool-activity'
6-
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
6+
import {
7+
getToolActivitySummaryActions,
8+
readToolActivity,
9+
} from '@/lib/mothership/tools/tool-activity'
10+
import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display'
711
import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream'
812
import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item'
913
import { getActivityAttentionKey } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions'
@@ -78,13 +82,17 @@ export function getActivityStatusTool(tools: ToolCallData[]): ToolCallData | und
7882
}
7983

8084
interface ToolActivityGroupProps {
85+
activity?: ToolActivity
86+
completedGroupCount?: number
8187
tools: ToolCallData[]
8288
ToolCallComponent: ComponentType<ToolCallItemProps>
8389
autoScrollActivity?: boolean
8490
isActive?: boolean
8591
}
8692

8793
export function ToolActivityGroup({
94+
activity,
95+
completedGroupCount = 0,
8896
tools,
8997
ToolCallComponent,
9098
autoScrollActivity = true,
@@ -93,12 +101,44 @@ export function ToolActivityGroup({
93101
const [expanded, setExpanded] = useState(false)
94102
const statusTool = getActivityStatusTool(tools)
95103
if (!statusTool) return null
96-
const working = isActive || tools.some((tool) => tool.status === ToolCallStatus.executing)
104+
const groupedActivity =
105+
activity ??
106+
tools
107+
.map((tool) => readToolActivity(tool.params, tool.streamingArgs))
108+
.findLast((entry) => entry?.title && entry.completedTitle)
109+
const working =
110+
(!groupedActivity && isActive) || tools.some((tool) => tool.status === ToolCallStatus.executing)
97111
const headerActive =
98112
working &&
99113
(statusTool.status === ToolCallStatus.executing || statusTool.status === ToolCallStatus.success)
100114
const attentionKey = getActivityAttentionKey(tools)
101115
const SummaryIcon = getToolIcon(tools[0].toolName)
116+
const activityTools =
117+
completedGroupCount > 1 && groupedActivity
118+
? tools.filter(
119+
(tool) => readToolActivity(tool.params, tool.streamingArgs)?.id === groupedActivity.id
120+
)
121+
: tools
122+
const failedActivityTool = activityTools.find(
123+
(tool) => tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected
124+
)
125+
const stoppedActivityTool = activityTools.find(
126+
(tool) =>
127+
tool.status === ToolCallStatus.cancelled ||
128+
tool.status === ToolCallStatus.interrupted ||
129+
tool.status === ToolCallStatus.skipped
130+
)
131+
const completedActivityLabel =
132+
groupedActivity?.title && groupedActivity.completedTitle
133+
? failedActivityTool
134+
? getToolStatusDisplayTitle(groupedActivity.title, ToolCallStatus.error)
135+
: stoppedActivityTool
136+
? getToolStatusDisplayTitle(groupedActivity.title, ToolCallStatus.cancelled)
137+
: groupedActivity.completedTitle
138+
: undefined
139+
const completedLabel = completedActivityLabel
140+
? `${completedActivityLabel}${completedGroupCount > 1 ? ` + ${completedGroupCount - 1}` : ''}`
141+
: undefined
102142

103143
return (
104144
<ToolCallComponent
@@ -109,9 +149,8 @@ export function ToolActivityGroup({
109149
activity={{
110150
label: working
111151
? getActiveToolActivityTitle(status.activeLabel, statusTool, tools)
112-
: tools.length === 1
113-
? status.label
114-
: getToolActivitySummary(tools),
152+
: (completedLabel ??
153+
(tools.length === 1 ? status.label : getToolActivitySummary(tools))),
115154
isActive: headerActive,
116155
icon:
117156
working || tools.length === 1 ? status.icon : <SummaryIcon className='size-full' />,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Terminal as TerminalTool } from '@/lib/copilot/generated/tool-catalog-v1'
1+
import { Terminal as TerminalTool } from '@/lib/mothership/generated/tool-catalog-v1'
22
import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
33

44
/** A permission decision or terminal handoff must stay reachable while an agent is waiting. */

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

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,135 @@ function mainToolCall(id: string, name: string): ContentBlock {
5858
return { type: 'tool_call', toolCall: { id, name, status: 'success' }, timestamp: 1 }
5959
}
6060

61+
describe('top-level activity groups', () => {
62+
const activityCall = (id: string, title?: string, spanId?: string): ContentBlock => ({
63+
type: 'tool_call',
64+
spanId,
65+
toolCall: {
66+
id,
67+
name: 'sim_cli',
68+
status: 'executing',
69+
params: title
70+
? {
71+
activity: { id: title, title, completedTitle: title.replace('Checking', 'Checked') },
72+
}
73+
: {},
74+
},
75+
})
76+
const activityReference = (id: string, activityId: string, spanId?: string): ContentBlock => ({
77+
type: 'tool_call',
78+
spanId,
79+
toolCall: {
80+
id,
81+
name: 'sim_cli',
82+
status: 'executing',
83+
params: { activity: { id: activityId } },
84+
},
85+
})
86+
87+
it.each([undefined, 'main'])(
88+
'keeps parallel activities separate and rejoins their original group (%s)',
89+
(spanId) => {
90+
const blocks = [
91+
activityCall('a1', 'Checking invoice inputs', spanId),
92+
activityReference('a2', 'Checking invoice inputs', spanId),
93+
activityCall('b1', 'Checking customer inputs', spanId),
94+
activityReference('a3', 'Checking invoice inputs', spanId),
95+
activityReference('a4', 'Checking invoice inputs', spanId),
96+
]
97+
const groups = parseBlocks(blocks).filter((segment) => segment.type === 'agent_group')
98+
expect(groups).toHaveLength(2)
99+
expect(
100+
groups.map((group) =>
101+
group.items.flatMap((item) => (item.type === 'tool' ? [item.data.id] : []))
102+
)
103+
).toEqual([['a1', 'a2', 'a3', 'a4'], ['b1']])
104+
const completed = blocks.map(
105+
(block): ContentBlock => ({
106+
...block,
107+
toolCall: block.toolCall ? { ...block.toolCall, status: 'success' } : undefined,
108+
})
109+
)
110+
const stillRunning = [...completed.slice(0, -1), blocks.at(-1)!]
111+
expect(parseBlocks(stillRunning)).toHaveLength(2)
112+
const merged = parseBlocks(completed)
113+
expect(merged).toHaveLength(1)
114+
expect(merged[0]).toMatchObject({
115+
id: groups[0].id,
116+
completedGroupCount: 2,
117+
activity: { completedTitle: 'Checked customer inputs' },
118+
items: blocks.map((block) => ({ type: 'tool', data: { id: block.toolCall!.id } })),
119+
})
120+
}
121+
)
122+
123+
it('collapses completed contiguous batches independently across text and reused ids', () => {
124+
const blocks = [
125+
activityCall('a1', 'Checking invoice inputs'),
126+
activityCall('b1', 'Checking customer inputs'),
127+
mainText('Now checking the updated inputs.'),
128+
activityReference('a2', 'Checking invoice inputs'),
129+
activityReference('b2', 'Checking customer inputs'),
130+
].map(
131+
(block): ContentBlock => ({
132+
...block,
133+
toolCall: block.toolCall ? { ...block.toolCall, status: 'success' } : undefined,
134+
})
135+
)
136+
const segments = parseBlocks(blocks)
137+
expect(segments.map((segment) => segment.type)).toEqual(['agent_group', 'text', 'agent_group'])
138+
const groups = segments.filter((segment) => segment.type === 'agent_group')
139+
expect(groups[0].id).not.toBe(groups[1].id)
140+
expect(groups.map((group) => group.completedGroupCount)).toEqual([2, 2])
141+
expect(groups.map((group) => group.activity?.completedTitle)).toEqual([
142+
'Checked customer inputs',
143+
'Checked customer inputs',
144+
])
145+
expect(
146+
groups.map((group) => group.items.map((item) => item.type === 'tool' && item.data.id))
147+
).toEqual([
148+
['a1', 'b1'],
149+
['a2', 'b2'],
150+
])
151+
})
152+
153+
it('keeps unassociated calls in a concrete fallback group instead of assigning another activity', () => {
154+
const groups = parseBlocks([
155+
activityCall('a1', 'Checking invoice inputs'),
156+
activityCall('b1'),
157+
activityReference('a2', 'Checking invoice inputs'),
158+
]).filter((segment) => segment.type === 'agent_group')
159+
expect(groups).toHaveLength(2)
160+
expect(groups[1].activity).toBeUndefined()
161+
expect(groups[1].items).toMatchObject([{ type: 'tool', data: { id: 'b1' } }])
162+
})
163+
164+
it('does not merge activities across prose or absorb a subagent into the main activity', () => {
165+
const segments = parseBlocks([
166+
activityCall('a1', 'Checking invoice inputs'),
167+
mainText('The invoice inputs are valid.'),
168+
activityReference('a2', 'Checking invoice inputs'),
169+
subagentStart('general', 'child', 'main'),
170+
{
171+
...activityCall('child-tool', 'Checking customer inputs', 'child'),
172+
toolCall: {
173+
...activityCall('child-tool', 'Checking customer inputs').toolCall!,
174+
calledBy: 'general',
175+
},
176+
},
177+
])
178+
expect(segments.map((segment) => segment.type)).toEqual([
179+
'agent_group',
180+
'text',
181+
'agent_group',
182+
'agent_group',
183+
])
184+
expect(
185+
segments.filter((segment) => segment.type === 'agent_group').map((group) => group.agentName)
186+
).toEqual(['mothership', 'mothership', 'general'])
187+
})
188+
})
189+
61190
function representativeToolArgs(entry: ToolCatalogEntry): Record<string, unknown> {
62191
const args: Record<string, unknown> = {}
63192
if (!entry.parameters || typeof entry.parameters !== 'object') return args

0 commit comments

Comments
 (0)