Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/auth-client', () => authClientMock)

import { toDisplayMessage } from '@/lib/mothership/chat/display-message'
import { normalizeMessage, stripToolResultOutput } from '@/lib/mothership/chat/persisted-message'
import {
normalizeMessage,
type PersistedMessage,
stripToolResultOutput,
} from '@/lib/mothership/chat/persisted-message'
import {
getTurnLiveIndicators,
ownsTurnWait,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity'
import {
contentBlocksToModel,
modelToContentBlocks,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
import type { ContentBlock } from '../../types'
import { getOrchestratorMessageText, parseBlocks } from './message-content'

Expand Down Expand Up @@ -242,6 +250,109 @@ describe('top-level activity groups', () => {
})
})

describe('async agent display names', () => {
const agentId = 'review-report-validatio-1'
const displayName = 'Review report validation'
const launch: ContentBlock = {
type: 'tool_call',
timestamp: 1,
toolCall: {
id: 'launch',
name: 'workflow',
status: 'success',
result: {
success: true,
output: { async: true, status: 'launched', agentId, name: displayName },
},
},
}
const wait: ContentBlock = {
type: 'tool_call',
timestamp: 2,
toolCall: {
id: 'wait',
name: 'wait_agents',
status: 'executing',
params: { agent_ids: [agentId, 'other-agent-2'] },
displayTitle: 'Waiting for Review Report Validatio + 1',
},
}
const waitTitle = (blocks: ContentBlock[]) =>
parseBlocks(blocks)
.flatMap((segment) => (segment.type === 'agent_group' ? segment.items : []))
.find((item) => item.type === 'tool' && item.data.id === 'wait')

it.each([false, true])(
'resolves launch names in live and reloaded traces (spans: %s)',
(spans) => {
const blocks = [
launch,
...(spans ? [subagentStart('research', 'research-span', 'main')] : []),
wait,
]
const original = structuredClone(blocks)
expect(waitTitle([wait])).toMatchObject({
data: { displayTitle: wait.toolCall?.displayTitle },
})
const expected = { data: { displayTitle: 'Waiting for Review report validation + 1' } }
expect(waitTitle(blocks)).toMatchObject(expected)
expect(waitTitle(modelToContentBlocks(contentBlocksToModel(blocks)))).toMatchObject(expected)
const saved: PersistedMessage = {
id: 'message',
role: 'assistant',
content: '',
timestamp: new Date(0).toISOString(),
contentBlocks: blocks
.flatMap((block) => (block.toolCall ? [block.toolCall] : []))
.map((toolCall) => ({
type: 'tool',
phase: 'call',
toolCall: {
id: toolCall.id,
name: toolCall.name,
state: toolCall.status,
params: toolCall.params,
result: toolCall.result,
display: { title: toolCall.displayTitle },
},
...(spans ? { spanId: 'main' } : {}),
})),
}
expect(
waitTitle(toDisplayMessage(stripToolResultOutput(saved)).contentBlocks ?? [])
).toMatchObject({
data: {
displayTitle: 'Stopped waiting for Review report validation + 1',
status: 'interrupted',
},
})
expect(blocks).toEqual(original)
expect(waitTitle([wait])).toMatchObject({
data: { displayTitle: wait.toolCall?.displayTitle },
})
}
)

it('ignores unrelated, failed, malformed and unnamed launch results', () => {
for (const patch of [
{ name: 'call_integration_tool' },
{ result: { success: false, output: launch.toolCall?.result?.output } },
{ result: { success: true, output: { async: true, agentId, name: displayName } } },
{
result: { success: true, output: { async: true, status: 'launched', agentId, name: ' ' } },
},
{ result: { success: true, output: null } },
]) {
const invalid = structuredClone(launch)
if (!invalid.toolCall) throw new Error('Expected an async launch tool call')
Object.assign(invalid.toolCall, patch)
expect(waitTitle([invalid, wait])).toMatchObject({
data: { displayTitle: wait.toolCall?.displayTitle },
})
}
})
})

describe('getOrchestratorMessageText', () => {
it('separates orchestrator text blocks around excluded subagent output', () => {
const blocks: ContentBlock[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { cn } from '@sim/emcn'
import { CircleStop } from '@sim/emcn/icons'
import { isPlainRecord } from '@sim/utils/object'
import { compactAsyncAgentLaunch } from '@/lib/mothership/chat/async-agent-display'
import type { ToolActivity } from '@/lib/mothership/generated/protocol'
import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1'
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
Expand Down Expand Up @@ -211,7 +212,19 @@ function mapToolStatusToClientState(
}
}

function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): string | undefined {
function getOverrideDisplayTitle(
tc: NonNullable<ContentBlock['toolCall']>,
agentNames: ReadonlyMap<string, string>
): string | undefined {
if (
agentNames.size > 0 &&
['wait_agents', 'tail_agent', 'steer_agent', 'interrupt_agent'].includes(tc.name)
) {
const ids = tc.name === 'wait_agents' ? tc.params?.agent_ids : [tc.params?.agent_id]
if (Array.isArray(ids) && ids.some((id) => typeof id === 'string' && agentNames.has(id))) {
return getToolDisplayTitle(tc.name, tc.params, agentNames)
}
}
if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) {
return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text
}
Expand All @@ -229,9 +242,12 @@ function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): str
return undefined
}

function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
function toToolData(
tc: NonNullable<ContentBlock['toolCall']>,
agentNames: ReadonlyMap<string, string>
): ToolCallData {
const activityDescription = normalizeToolActivityDescription(tc.activityDescription)
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
const overrideDisplayTitle = getOverrideDisplayTitle(tc, agentNames)
const resolvedTitle =
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
const displayTitle = getToolStatusDisplayTitle(
Expand Down Expand Up @@ -290,7 +306,10 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
* no name/tool-call reverse lookups. Delegation tool_calls are absorbed — the
* subagent span is the canonical representation of the nested agent.
*/
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
function parseBlocksWithSpanTree(
blocks: ContentBlock[],
agentNames: ReadonlyMap<string, string>
): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsBySpanId = new Map<string, AgentGroupSegment>()
// Stable per-run counters for React keys. The Nth top-level text run / Nth
Expand Down Expand Up @@ -475,7 +494,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
// Delegation tools are represented by their subagent span group; absorb.
if (SUBAGENT_KEYS.has(tc.name)) continue
const tool = toToolData(tc)
const tool = toToolData(tc, agentNames)
if (block.spanId) {
let g = groupsBySpanId.get(block.spanId)
// Out-of-order safety: a subagent's tool can stream before its
Expand Down Expand Up @@ -603,6 +622,15 @@ function groupByActivity(segments: MessageSegment[], isStreaming: boolean): Mess
}

export function parseBlocks(blocks: ContentBlock[], isStreaming = false): MessageSegment[] {
/** Launch results retain display names; their slugified IDs can cut words short. */
const agentNames = new Map<string, string>()
for (const block of blocks) {
if (block.type !== 'tool_call') continue
const tc = block.toolCall
if (!tc?.result?.success) continue
const launch = compactAsyncAgentLaunch(tc.name, tc.result.output)
if (launch) agentNames.set(launch.agentId, launch.name)
}
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
const watches = new Set(
blocks.flatMap((block) => (block.type === 'task' && block.task ? [block.task.taskId] : []))
)
Expand All @@ -618,8 +646,8 @@ export function parseBlocks(blocks: ContentBlock[], isStreaming = false): Messag
})
return groupByActivity(
blocks.some((block) => Boolean(block.spanId))
? parseBlocksWithSpanTree(visibleBlocks)
: parseBlocksLegacy(visibleBlocks),
? parseBlocksWithSpanTree(visibleBlocks, agentNames)
: parseBlocksLegacy(visibleBlocks, agentNames),
isStreaming
)
}
Expand All @@ -643,7 +671,10 @@ export function getOrchestratorMessageText(
return getOrchestratorMessageTextSegments(blocks, fallbackContent).join('\n\n')
}

function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
function parseBlocksLegacy(
blocks: ContentBlock[],
agentNames: ReadonlyMap<string, string>
): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsByKey = new Map<string, AgentGroupSegment>()
let activeGroupKey: string | null = null
Expand Down Expand Up @@ -797,7 +828,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
continue
}

const tool = toToolData(tc)
const tool = toToolData(tc, agentNames)

if (tc.calledBy) {
const { group: g, created } = ensureGroup(tc.calledBy, block.parentToolCallId)
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/mothership/chat/async-agent-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { isPlainRecord } from '@sim/utils/object'
import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1'

/** Retains only bounded launch identity for labels, never the agent's task or result. */
export function compactAsyncAgentLaunch(toolName: string, output: unknown) {
if (
TOOL_CATALOG[toolName]?.route !== 'subagent' ||
!isPlainRecord(output) ||
output.async !== true ||
output.status !== 'launched' ||
typeof output.agentId !== 'string' ||
!output.agentId ||
output.agentId.length > 128 ||
typeof output.name !== 'string' ||
!output.name.trim() ||
output.name.length > 256
) {
return undefined
}
return { async: true, status: 'launched', agentId: output.agentId, name: output.name }
}
47 changes: 47 additions & 0 deletions apps/sim/lib/mothership/chat/persisted-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,53 @@ describe('persisted-message', () => {
})

describe('stripToolResultOutput', () => {
it('keeps only bounded successful async launch identity for display', () => {
const launch = {
async: true,
status: 'launched',
agentId: 'review-report-1',
name: 'Review report',
}
const message: PersistedMessage = {
id: 'message',
role: 'assistant',
content: '',
timestamp: new Date(0).toISOString(),
contentBlocks: [
{
type: 'tool',
phase: 'call',
toolCall: {
id: 'launch',
name: 'workflow',
state: 'success',
result: {
success: true,
output: { ...launch, note: 'large content', task: 'private task' },
},
},
},
],
}
expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({
success: true,
output: launch,
})
for (const output of [
{ ...launch, agentId: 'x'.repeat(129) },
{ ...launch, name: 'x'.repeat(257) },
{ ...launch, async: false },
]) {
const invalid = structuredClone(message)
const result = invalid.contentBlocks?.[0].toolCall?.result
if (!result) throw new Error('Expected an async launch result')
result.output = output
expect(stripToolResultOutput(invalid).contentBlocks?.[0].toolCall?.result).toEqual({
success: true,
})
}
})

it('keeps the partial-coverage marker of an empty search through save and reload', () => {
const message: PersistedMessage = {
id: 'msg-search',
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/mothership/chat/persisted-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
PersistedToolCall,
PersistedToolState,
} from '@/lib/api/contracts/copilot-messages'
import { compactAsyncAgentLaunch } from '@/lib/mothership/chat/async-agent-display'
import { buildMothershipErrorTag } from '@/lib/mothership/chat/error-tag'
import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations'
import {
Expand Down Expand Up @@ -127,8 +128,9 @@ export interface PersistedMessage {

/**
* Drop persisted tool outputs, keeping `success` and `error`. Bounded UI-state
* exceptions preserve retrieval citations, watch-to-task identity, and the
* browser takeover's answered question recap. Other outputs are never
* exceptions preserve retrieval citations, async agent launch names,
* watch-to-task identity, and the browser takeover's answered question recap.
* Other outputs are never
* rendered or replayed to the model (the upstream service owns conversation
* memory), so storing them only bloats
* `copilot_messages.content` — a single `get_workflow_logs`/`run_workflow`
Expand All @@ -148,6 +150,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa
if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block
const output = result.output
const citations = result.success ? compactRetrievalCitations(toolCall.name, output) : undefined
const agentLaunch = result.success ? compactAsyncAgentLaunch(toolCall.name, output) : undefined
const taskId =
result.success && toolCall.name === 'watch' && isPlainRecord(output)
? output.taskId
Expand All @@ -174,6 +177,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa
const strippedResult: { success: boolean; output?: unknown; error?: string } = {
success: result.success,
...(citations ? { output: citations } : {}),
...(agentLaunch ? { output: agentLaunch } : {}),
...(watchReceipt ? { output: watchReceipt } : {}),
...(normalizedInstruction ? { output: { userInstruction: normalizedInstruction } } : {}),
}
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/lib/mothership/tools/display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display'

describe('async agent titles', () => {
const id = 'review-report-validatio-1'
const names = new Map([[id, 'Review report validation']])

it('preserves display names, wait modes, counts and unknown-ID fallbacks', () => {
expect(getToolDisplayTitle('wait_agents', { agent_ids: [id] }, names)).toBe(
'Waiting for Review report validation'
)
expect(
getToolDisplayTitle('wait_agents', { agent_ids: [id, 'other-agent-2'], mode: 'any' }, names)
).toBe('Waiting for the first of Review report validation + 1')
expect(getToolDisplayTitle('wait_agents', { agent_ids: ['other-agent-2'] }, names)).toBe(
'Waiting for Other Agent'
)
expect(getToolDisplayTitle('wait_agents', { agent_ids: [] }, names)).toBe('Waiting for agents')
})

it.each([
['tail_agent', 'Checking on'],
['steer_agent', 'Steering'],
['interrupt_agent', 'Stopping'],
])('uses the same display name for %s', (tool, verb) => {
expect(getToolDisplayTitle(tool, { agent_id: id }, names)).toBe(
`${verb} Review report validation`
)
})
})
Loading
Loading