Skip to content

Commit 155f2a4

Browse files
committed
Keep Mothership resource panels and activity groups in sync
1 parent d97ef16 commit 155f2a4

38 files changed

Lines changed: 1743 additions & 121 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { NextRequest } from 'next/server'
2+
import {
3+
type MothershipSandboxParams,
4+
mothershipSandboxParamsSchema,
5+
} from '@/lib/api/contracts/mothership-sandbox'
6+
import { validationErrorResponse } from '@/lib/api/server'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { proxySandboxResourceRequest } from '@/lib/mothership/tools/sandbox-resource-transport'
9+
10+
export const dynamic = 'force-dynamic'
11+
12+
/** Raw transport boundary preserves v2 JSON, multipart and binary bodies; v2 owns operation authorization. */
13+
const handler = withRouteHandler(
14+
async (request: NextRequest, context: { params: Promise<MothershipSandboxParams> }) => {
15+
const params = mothershipSandboxParamsSchema.safeParse(await context.params)
16+
if (!params.success) return validationErrorResponse(params.error)
17+
return proxySandboxResourceRequest(request, params.data.token)
18+
}
19+
)
20+
21+
export const GET = handler
22+
export const HEAD = handler
23+
export const POST = handler
24+
export const PUT = handler
25+
export const PATCH = handler
26+
export const DELETE = handler
27+
export const OPTIONS = handler

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

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

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

320-
const render = (items: AgentGroupItem[], isStreaming = true, completedGroupCount = 0) => {
320+
const render = (
321+
items: AgentGroupItem[],
322+
isStreaming = true,
323+
completedGroupCount = 0,
324+
isLaneOpen = isStreaming
325+
) => {
321326
act(() => {
322327
root.render(
323328
createElement(AgentGroup, {
@@ -326,7 +331,7 @@ describe('AgentGroup main tool summary', () => {
326331
items,
327332
completedGroupCount,
328333
isStreaming,
329-
isLaneOpen: isStreaming,
334+
isLaneOpen,
330335
})
331336
)
332337
})
@@ -382,7 +387,7 @@ describe('AgentGroup main tool summary', () => {
382387
call('Start', 'success', 'Checking start inputs'),
383388
call('Function', 'success'),
384389
]
385-
const header = render(items, true, 2)
390+
const header = render(items, false, 2)
386391
expect(header.textContent).toBe('Checked start inputs + 1')
387392
expect(header.getAttribute('aria-expanded')).toBe('false')
388393
act(() => header.click())
@@ -401,6 +406,41 @@ describe('AgentGroup main tool summary', () => {
401406
}
402407
)
403408

409+
it('keeps broad activity present through a read, thinking gap, and later creation', () => {
410+
const read = call('Exa', 'executing', 'Building Search API')
411+
const header = render([read])
412+
expect(header.textContent).toBe('Building Search API')
413+
const readDone = call('Exa', 'success', 'Building Search API')
414+
render([readDone])
415+
expect(header.textContent).toBe('Building Search API')
416+
const create: AgentGroupItem = {
417+
type: 'tool',
418+
data: {
419+
id: 'create',
420+
toolName: 'cli_workflows_create',
421+
displayTitle: 'Creating Search API',
422+
status: 'executing',
423+
params: { activity: { id: 'Building Search API' } },
424+
},
425+
}
426+
render([readDone, create])
427+
expect(header.textContent).toBe('Building Search API')
428+
const createDone: AgentGroupItem = { ...create, data: { ...create.data, status: 'success' } }
429+
render([readDone, createDone])
430+
expect(header.textContent).toBe('Building Search API')
431+
render([readDone, createDone], false)
432+
expect(header.textContent).toBe('Built Search API')
433+
})
434+
435+
it('uses past tense after a visible boundary only once associated tools resolve', () => {
436+
const header = render([call('Exa', 'executing', 'Building Search API')], true, 0, false)
437+
expect(header.textContent).toBe('Building Search API')
438+
render([call('Exa', 'success', 'Building Search API')], true, 0, false)
439+
expect(header.textContent).toBe('Built Search API')
440+
render([call('Start', 'success', 'Building Search API')], true)
441+
expect(header.textContent).toBe('Building Search API')
442+
})
443+
404444
it('does not attribute another activity failure to the representative completed activity', () => {
405445
const header = render(
406446
[
@@ -434,7 +474,7 @@ describe('AgentGroup main tool summary', () => {
434474
},
435475
})
436476
const header = render([streaming('{"activity":{"id":"inputs","title":"Checking')])
437-
expect(header.textContent).toBe('Working…')
477+
expect(header.textContent).toBe('Preparing tool call…')
438478
render([
439479
streaming(
440480
'{"activity":{"id":"inputs","title":"Checking search inputs","completedTitle":"Checked search inputs"},"args":['
@@ -468,6 +508,33 @@ describe('AgentGroup main tool summary', () => {
468508
expect(header.textContent).toBe('Reading Exa configuration')
469509
})
470510

511+
it('describes a later generic call as preparation until its arguments identify the work', () => {
512+
const pending: AgentGroupItem = {
513+
type: 'tool',
514+
data: {
515+
id: 'next',
516+
toolName: 'run_code',
517+
displayTitle: 'Running code',
518+
status: 'executing',
519+
params: { activity: { id: 'unlabeled' } },
520+
streamingArgs: '{"code":"const',
521+
},
522+
}
523+
const header = render([call('Exa', 'success'), pending])
524+
expect(header.textContent).toBe('Preparing tool call…')
525+
render([
526+
call('Exa', 'success'),
527+
{ ...pending, data: { ...pending.data, params: { code: '1' } } },
528+
])
529+
expect(header.textContent).toBe('Running code')
530+
})
531+
532+
it('keeps a pending approval current when a later call has already succeeded', () => {
533+
const header = render([call('Exa', 'awaiting_approval'), call('Start', 'success')])
534+
expect(header.textContent).toBe('Waiting for approval: Reading Exa configuration')
535+
expect(header.getAttribute('aria-expanded')).toBe('true')
536+
})
537+
471538
it('does not let a nested agent rename the parent or treat a resource title as intent', () => {
472539
const header = render([
473540
call('Exa', 'executing', 'Checking search inputs'),
@@ -505,7 +572,7 @@ describe('AgentGroup main tool summary', () => {
505572
expect(header.getAttribute('aria-expanded')).toBe('false')
506573
})
507574

508-
it.each(['success', 'error', 'cancelled'] as const)(
575+
it.each(['success', 'error', 'cancelled', 'interrupted', 'skipped'] as const)(
509576
'shows an honest terminal label for %s with no remaining-call count',
510577
(status) => {
511578
const header = render([tool('success'), tool(status)], false)
@@ -514,6 +581,8 @@ describe('AgentGroup main tool summary', () => {
514581
success: 'Searched',
515582
error: 'Failed searching',
516583
cancelled: 'Stopped searching',
584+
interrupted: 'Stopped searching',
585+
skipped: 'Stopped searching',
517586
}[status]
518587
)
519588
expect(header.getAttribute('aria-expanded')).toBe('false')

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ interface AgentGroupProps {
4545
items: AgentGroupItem[]
4646
isDelegating?: boolean
4747
isStreaming?: boolean
48-
/** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */
48+
/** The subagent lane or visible main tool batch can still receive more work. */
4949
isLaneOpen?: boolean
5050
}
5151

@@ -165,20 +165,18 @@ export function AgentGroup({
165165
const status = useMemo(() => {
166166
if (!isMainAgent && !isLaneOpen) return undefined
167167
const tools = collectGroupTools(items)
168-
const running = tools.filter((tool) => tool.status === ToolCallStatus.executing)
168+
const running = tools.filter((tool) => !isToolDone(tool.status))
169169
const latest = running.length
170170
? running.reduce((newest, tool) =>
171171
(tool.startedAt ?? 0) >= (newest.startedAt ?? 0) ? tool : newest
172172
)
173173
: tools.at(-1)
174174
if (!latest) return undefined
175175
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
176+
const generatingCall =
177+
latest.status === ToolCallStatus.executing &&
178+
(latest.toolName === 'sim_cli' || latest.toolName === 'run_code') &&
179+
Object.keys(latest.params ?? {}).every((key) => key === 'activity')
182180
const activityTools =
183181
completedGroupCount > 1 && activity
184182
? tools.filter(
@@ -188,22 +186,22 @@ export function AgentGroup({
188186
const failed = activityTools.find(
189187
(tool) => tool.status !== ToolCallStatus.success && isToolDone(tool.status)
190188
)
191-
const activityComplete = !failed && isAgentGroupResolved(items)
189+
const activityComplete = !failed && (!isStreaming || !isLaneOpen) && isAgentGroupResolved(items)
192190
const mainTitle =
193191
activity?.title && activity.completedTitle
194192
? activityComplete
195193
? activity.completedTitle
196194
: activity.title
197-
: generatingFirstCall
198-
? 'Working…'
195+
: generatingCall
196+
? 'Preparing tool call…'
199197
: toolStatusTitle(latest)
200198
return {
201199
title: isMainAgent
202200
? getToolStatusDisplayTitle(
203201
mainTitle,
204202
activity
205203
? running.length > 0
206-
? 'executing'
204+
? latest.status
207205
: failed
208206
? failed.status === 'error' || failed.status === 'rejected'
209207
? 'error'
@@ -215,7 +213,7 @@ export function AgentGroup({
215213
: toolStatusTitle(latest),
216214
additionalCount: Math.max(0, (isMainAgent ? completedGroupCount : 0) - 1, running.length - 1),
217215
}
218-
}, [isLaneOpen, isMainAgent, items, groupActivity, completedGroupCount])
216+
}, [isLaneOpen, isMainAgent, isStreaming, items, groupActivity, completedGroupCount])
219217
const AgentIcon = getAgentIcon(agentName)
220218
const headerText = error
221219
? isMainAgent

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,43 @@ describe('top-level activity groups', () => {
161161
expect(groups[1].items).toMatchObject([{ type: 'tool', data: { id: 'b1' } }])
162162
})
163163

164+
it('keeps parallel activities open through thinking gaps until their batch closes', () => {
165+
const completed = [
166+
activityCall('a1', 'Checking invoice inputs'),
167+
activityCall('b1', 'Checking customer inputs'),
168+
activityReference('a2', 'Checking invoice inputs'),
169+
].map(
170+
(block): ContentBlock => ({
171+
...block,
172+
toolCall: { ...block.toolCall!, status: 'success' },
173+
})
174+
)
175+
const open = parseBlocks(completed, true)
176+
expect(open).toHaveLength(2)
177+
expect(open).toMatchObject([
178+
{ isOpen: true, items: [{ data: { id: 'a1' } }, { data: { id: 'a2' } }] },
179+
{ isOpen: true, items: [{ data: { id: 'b1' } }] },
180+
])
181+
const settled = parseBlocks(completed, false)
182+
expect(settled).toHaveLength(1)
183+
expect(settled[0]).toMatchObject({ isOpen: false, completedGroupCount: 2 })
184+
const proseClosed = parseBlocks([...completed, mainText('The inputs are ready.')], true)
185+
expect(proseClosed[0]).toMatchObject({ isOpen: false, completedGroupCount: 2 })
186+
})
187+
188+
it('closes at prose and reopens a reused activity below it during the same turn', () => {
189+
const first = activityCall('a1', 'Checking invoice inputs')
190+
first.toolCall!.status = 'success'
191+
const reused = activityReference('a2', 'Checking invoice inputs')
192+
reused.toolCall!.status = 'success'
193+
const segments = parseBlocks([first, mainText('Checking the updated inputs.'), reused], true)
194+
expect(segments).toMatchObject([
195+
{ type: 'agent_group', isOpen: false, activity: { id: 'Checking invoice inputs' } },
196+
{ type: 'text' },
197+
{ type: 'agent_group', isOpen: true, activity: { id: 'Checking invoice inputs' } },
198+
])
199+
})
200+
164201
it('does not merge activities across prose or absorb a subagent into the main activity', () => {
165202
const segments = parseBlocks([
166203
activityCall('a1', 'Checking invoice inputs'),

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
539539
}
540540

541541
/** Each explicit activity owns a top-level group, including interleaved parallel calls. */
542-
function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
542+
function groupByActivity(segments: MessageSegment[], isStreaming: boolean): MessageSegment[] {
543543
const labels = new Map<string, ToolActivity>()
544544
for (const segment of segments) {
545545
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') continue
@@ -550,8 +550,10 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
550550
labels.set(activity.id, activity)
551551
}
552552
}
553-
return segments.flatMap((segment): MessageSegment[] => {
553+
return segments.flatMap((segment, index): MessageSegment[] => {
554554
if (segment.type !== 'agent_group' || segment.agentName !== 'mothership') return [segment]
555+
/** A quiet model round can append calls to any activity in this visible batch. */
556+
const isOpen = isStreaming && index === segments.length - 1
555557
const groups: AgentGroupSegment[] = []
556558
const byActivity = new Map<string, AgentGroupSegment>()
557559
let current: AgentGroupSegment | undefined
@@ -565,6 +567,7 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
565567
else if (!current || activity || currentActivityId) {
566568
current = {
567569
...segment,
570+
isOpen,
568571
activity: activity ? labels.get(activity.id) : undefined,
569572
id:
570573
groups.length === 0
@@ -582,6 +585,7 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
582585
const summaryActivity = [...groups].reverse().find((group) => group.activity)?.activity
583586
if (
584587
groups.length > 1 &&
588+
!isOpen &&
585589
summaryActivity &&
586590
segment.items.every((item) => item.type === 'tool') &&
587591
isAgentGroupResolved(segment.items)
@@ -592,11 +596,12 @@ function groupByActivity(segments: MessageSegment[]): MessageSegment[] {
592596
})
593597
}
594598

595-
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
599+
export function parseBlocks(blocks: ContentBlock[], isStreaming = false): MessageSegment[] {
596600
return groupByActivity(
597601
blocks.some((block) => Boolean(block.spanId))
598602
? parseBlocksWithSpanTree(blocks)
599-
: parseBlocksLegacy(blocks)
603+
: parseBlocksLegacy(blocks),
604+
isStreaming
600605
)
601606
}
602607

@@ -969,8 +974,8 @@ function MessageContentInner({
969974
const blockOverlayVersion = useCustomBlockOverlayVersion()
970975
const titledBlocks = useToolResourceTitles(blocks)
971976
const parsed = useMemo(
972-
() => (titledBlocks.length > 0 ? parseBlocks(titledBlocks) : []),
973-
[titledBlocks, blockOverlayVersion]
977+
() => (titledBlocks.length > 0 ? parseBlocks(titledBlocks, isStreaming) : []),
978+
[titledBlocks, blockOverlayVersion, isStreaming]
974979
)
975980

976981
const [trailingRevealing, setTrailingRevealing] = useState(false)

0 commit comments

Comments
 (0)