diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 4dbf06d5e6f..612867d0441 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -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' @@ -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[] = [ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index e04ea68b9bd..8d15aa5fab7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -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' @@ -211,7 +212,19 @@ function mapToolStatusToClientState( } } -function getOverrideDisplayTitle(tc: NonNullable): string | undefined { +function getOverrideDisplayTitle( + tc: NonNullable, + agentNames: ReadonlyMap +): 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 } @@ -229,9 +242,12 @@ function getOverrideDisplayTitle(tc: NonNullable): str return undefined } -function toToolData(tc: NonNullable): ToolCallData { +function toToolData( + tc: NonNullable, + agentNames: ReadonlyMap +): 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( @@ -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 +): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() // Stable per-run counters for React keys. The Nth top-level text run / Nth @@ -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 @@ -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() + 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) + } const watches = new Set( blocks.flatMap((block) => (block.type === 'task' && block.task ? [block.task.taskId] : [])) ) @@ -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 ) } @@ -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 +): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() let activeGroupKey: string | null = null @@ -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) diff --git a/apps/sim/lib/mothership/chat/async-agent-display.ts b/apps/sim/lib/mothership/chat/async-agent-display.ts new file mode 100644 index 00000000000..6e09a642a4f --- /dev/null +++ b/apps/sim/lib/mothership/chat/async-agent-display.ts @@ -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 } +} diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts index e8cc7dd0451..08513f1ff8e 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -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', diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index 9771edb2973..41e207f4168 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -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 { @@ -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` @@ -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 @@ -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 } } : {}), } diff --git a/apps/sim/lib/mothership/tools/display.test.ts b/apps/sim/lib/mothership/tools/display.test.ts new file mode 100644 index 00000000000..a11bef94174 --- /dev/null +++ b/apps/sim/lib/mothership/tools/display.test.ts @@ -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` + ) + }) +}) diff --git a/apps/sim/lib/mothership/tools/tool-display.ts b/apps/sim/lib/mothership/tools/tool-display.ts index f67921ef0c4..e401076fec2 100644 --- a/apps/sim/lib/mothership/tools/tool-display.ts +++ b/apps/sim/lib/mothership/tools/tool-display.ts @@ -761,18 +761,20 @@ function waitTitle(args: ToolArgs): string { /** * An async agent id is its slugified display name plus a sequence suffix - * ("digest-workflow-build-4"); recover the human name for titles. + * ("digest-workflow-build-4"). Prefer its display name: the slug may be truncated. */ -function humanizeAgentId(id: string): string { +function humanizeAgentId(id: string, agentNames?: ReadonlyMap): string { + const displayName = agentNames?.get(id) + if (displayName) return displayName const stem = id.replace(/-\d+$/, '') return stem ? humanizeDisplayIdentifier(stem) : id } /** Title for a wait_agents sleep, naming the agents and honoring mode "any". */ -function waitAgentsTitle(args: ToolArgs): string { +function waitAgentsTitle(args: ToolArgs, agentNames?: ReadonlyMap): string { const raw = args?.agent_ids const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] - const names = ids.map(humanizeAgentId) + const names = ids.map((id) => humanizeAgentId(id, agentNames)) const anyMode = stringArg(args, 'mode') === 'any' if (names.length === 1) return `Waiting for ${names[0]}` if (names.length > 1) { @@ -898,9 +900,13 @@ function cliServiceDisplay( * cases come first, then the static map, then a humanized fallback. This never * returns an empty string. */ -export function getToolDisplayTitle(name: string, args?: Record): string { +export function getToolDisplayTitle( + name: string, + args?: Record, + agentNames?: ReadonlyMap +): string { const service = cliServiceDisplay(name, args) - if (service) return getToolDisplayTitle(service.name, service.input) + if (service) return getToolDisplayTitle(service.name, service.input, agentNames) const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) if (mcpToolMatch?.[1]) { return humanizeToolName(mcpToolMatch[1]) @@ -1048,13 +1054,13 @@ export function getToolDisplayTitle(name: string, args?: Record case 'wait': return waitTitle(args) case 'wait_agents': - return waitAgentsTitle(args) + return waitAgentsTitle(args, agentNames) case 'tail_agent': - return `Checking on ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` + return `Checking on ${humanizeAgentId(stringArg(args, 'agent_id'), agentNames) || 'agent'}` case 'steer_agent': - return `Steering ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` + return `Steering ${humanizeAgentId(stringArg(args, 'agent_id'), agentNames) || 'agent'}` case 'interrupt_agent': - return `Stopping ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` + return `Stopping ${humanizeAgentId(stringArg(args, 'agent_id'), agentNames) || 'agent'}` case 'terminal': return terminalTitle(args) // The surface used to be one tool per operation. Conversations recorded