From d3d527ea305efc0d75633b1888420c0ea2dc8ffd Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 9 Sep 2026 14:26:22 -0700 Subject: [PATCH] fix(chat): preserve async agent display names in traces --- .../message-content/message-content.test.ts | 104 +++++++++++++++++- .../message-content/message-content.tsx | 49 +++++++-- .../lib/copilot/chat/async-agent-display.ts | 21 ++++ .../copilot/chat/persisted-message.test.ts | 45 ++++++++ .../sim/lib/copilot/chat/persisted-message.ts | 9 +- .../lib/copilot/tools/tool-display.test.ts | 28 +++++ apps/sim/lib/copilot/tools/tool-display.ts | 24 ++-- 7 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 apps/sim/lib/copilot/chat/async-agent-display.ts 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 76b2976c67c..63a9e5b10fa 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 @@ -14,6 +14,8 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) +import { toDisplayMessage } from '@/lib/copilot/chat/display-message' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' @@ -22,7 +24,10 @@ import { createTurnModel, reduceEvent, } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' -import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' +import { + contentBlocksToModel, + modelToContentBlocks, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' import type { ContentBlock } from '../../types' import { assistantMessageHasVisibleExecutingTool, @@ -101,6 +106,103 @@ function toolEnvelope( } as PersistedStreamEventEnvelope } +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 + .filter((block) => block.toolCall) + .map((block) => ({ + type: 'tool', + phase: 'call', + toolCall: { + id: block.toolCall!.id, + name: block.toolCall!.name, + state: block.toolCall!.status, + params: block.toolCall!.params, + result: block.toolCall!.result, + display: { title: block.toolCall!.displayTitle }, + }, + ...(spans ? { spanId: 'main' } : {}), + })), + } + expect( + waitTitle(toDisplayMessage(stripToolResultOutput(saved)).contentBlocks ?? []) + ).toMatchObject(expected) + 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) + Object.assign(invalid.toolCall!, patch) + expect(waitTitle([invalid, wait])).toMatchObject({ + data: { displayTitle: wait.toolCall?.displayTitle }, + }) + } + }) +}) + describe('getOrchestratorMessageText', () => { it('copies only orchestrator text from span-based messages', () => { 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 7828f6b187d..b05a445ddf7 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 @@ -11,6 +11,7 @@ import { useState, } from 'react' import { cn } from '@sim/emcn' +import { compactAsyncAgentLaunch } from '@/lib/copilot/chat/async-agent-display' import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' @@ -181,7 +182,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 } @@ -199,8 +212,11 @@ function getOverrideDisplayTitle(tc: NonNullable): str return undefined } -function toToolData(tc: NonNullable): ToolCallData { - const overrideDisplayTitle = getOverrideDisplayTitle(tc) +function toToolData( + tc: NonNullable, + agentNames: ReadonlyMap +): ToolCallData { + const overrideDisplayTitle = getOverrideDisplayTitle(tc, agentNames) const resolvedTitle = overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status, tc.name) @@ -253,7 +269,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 @@ -422,7 +441,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 @@ -500,10 +519,19 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { * span identity existed. */ export function parseBlocks(blocks: ContentBlock[]): 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) + } if (blocks.some((block) => Boolean(block.spanId))) { - return parseBlocksWithSpanTree(blocks) + return parseBlocksWithSpanTree(blocks, agentNames) } - return parseBlocksLegacy(blocks) + return parseBlocksLegacy(blocks, agentNames) } function joinRenderableText(parts: string[]): string { @@ -523,7 +551,10 @@ export function getOrchestratorMessageText( ) } -function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { +function parseBlocksLegacy( + blocks: ContentBlock[], + agentNames: ReadonlyMap +): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() let activeGroupKey: string | null = null @@ -677,7 +708,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/copilot/chat/async-agent-display.ts b/apps/sim/lib/copilot/chat/async-agent-display.ts new file mode 100644 index 00000000000..6d2c61cdad3 --- /dev/null +++ b/apps/sim/lib/copilot/chat/async-agent-display.ts @@ -0,0 +1,21 @@ +import { isPlainRecord } from '@sim/utils/object' +import { TOOL_CATALOG } from '@/lib/copilot/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/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index 71a3f292156..ad129ae49eb 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -380,6 +380,51 @@ 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) + invalid.contentBlocks![0].toolCall!.result!.output = output + expect(stripToolResultOutput(invalid).contentBlocks?.[0].toolCall?.result).toEqual({ + success: true, + }) + } + }) + it('drops result.output but keeps success and error', () => { const message: PersistedMessage = { id: 'msg-1', diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 85db3536dc6..8297e13b70c 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -1,5 +1,6 @@ import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' +import { compactAsyncAgentLaunch } from '@/lib/copilot/chat/async-agent-display' import { compactRetrievalCitations } from '@/lib/copilot/chat/retrieval-citations' import { mergeAndRedactPersistedBlocks, @@ -132,9 +133,9 @@ export interface PersistedMessage { } /** - * Drop persisted tool outputs, keeping `success` and `error`. The one narrow - * UI-state exceptions are bounded retrieval citations and a browser takeover's user-authored instruction, which - * restores its answered question recap after reload. Other outputs are never + * Drop persisted tool outputs, keeping `success` and `error`. Narrow UI-state + * exceptions retain bounded retrieval citations, async agent launch names, and + * a browser takeover's user-authored instruction for display after reload. 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` @@ -154,6 +155,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 userInstruction = toolCall.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && isPlainRecord(output) ? output.userInstruction @@ -171,6 +173,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa const strippedResult: { success: boolean; output?: unknown; error?: string } = { success: result.success, ...(citations ? { output: citations } : {}), + ...(agentLaunch ? { output: agentLaunch } : {}), ...(normalizedInstruction ? { output: { userInstruction: normalizedInstruction } } : {}), } if (result.error !== undefined) strippedResult.error = result.error diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 89e15b8c3fd..ef9e732a21c 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -22,6 +22,34 @@ import { mvDisplayVerb, } from '@/lib/copilot/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` + ) + }) +}) + function representativeToolArgs(entry: ToolCatalogEntry): Record { const args: Record = {} if (!entry.parameters || typeof entry.parameters !== 'object') return args diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index da2408eb90b..b941fea9afc 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -722,19 +722,21 @@ 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 words = id.replace(/-\d+$/, '').split('-').filter(Boolean) if (words.length === 0) return id return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') } /** 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) { @@ -820,7 +822,11 @@ function terminalTitle(args: ToolArgs): string { * 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 mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) if (mcpToolMatch?.[1]) { return humanizeToolName(mcpToolMatch[1]) @@ -859,13 +865,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