Skip to content

Commit d3d527e

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(chat): preserve async agent display names in traces
1 parent 8ec065f commit d3d527e

7 files changed

Lines changed: 258 additions & 22 deletions

File tree

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

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ vi.mock('@/lib/auth/auth-client', () => ({
1414
useSession: vi.fn(() => ({ data: null, isPending: false })),
1515
}))
1616

17+
import { toDisplayMessage } from '@/lib/copilot/chat/display-message'
18+
import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message'
1719
import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1'
1820
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
1921
import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools'
@@ -22,7 +24,10 @@ import {
2224
createTurnModel,
2325
reduceEvent,
2426
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
25-
import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
27+
import {
28+
contentBlocksToModel,
29+
modelToContentBlocks,
30+
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
2631
import type { ContentBlock } from '../../types'
2732
import {
2833
assistantMessageHasVisibleExecutingTool,
@@ -101,6 +106,103 @@ function toolEnvelope(
101106
} as PersistedStreamEventEnvelope
102107
}
103108

109+
describe('async agent display names', () => {
110+
const agentId = 'review-report-validatio-1'
111+
const displayName = 'Review report validation'
112+
const launch: ContentBlock = {
113+
type: 'tool_call',
114+
timestamp: 1,
115+
toolCall: {
116+
id: 'launch',
117+
name: 'workflow',
118+
status: 'success',
119+
result: {
120+
success: true,
121+
output: { async: true, status: 'launched', agentId, name: displayName },
122+
},
123+
},
124+
}
125+
const wait: ContentBlock = {
126+
type: 'tool_call',
127+
timestamp: 2,
128+
toolCall: {
129+
id: 'wait',
130+
name: 'wait_agents',
131+
status: 'executing',
132+
params: { agent_ids: [agentId, 'other-agent-2'] },
133+
displayTitle: 'Waiting for Review Report Validatio + 1',
134+
},
135+
}
136+
const waitTitle = (blocks: ContentBlock[]) =>
137+
parseBlocks(blocks)
138+
.flatMap((segment) => (segment.type === 'agent_group' ? segment.items : []))
139+
.find((item) => item.type === 'tool' && item.data.id === 'wait')
140+
141+
it.each([false, true])(
142+
'resolves launch names in live and reloaded traces (spans: %s)',
143+
(spans) => {
144+
const blocks = [
145+
launch,
146+
...(spans ? [subagentStart('research', 'research-span', 'main')] : []),
147+
wait,
148+
]
149+
const original = structuredClone(blocks)
150+
expect(waitTitle([wait])).toMatchObject({
151+
data: { displayTitle: wait.toolCall?.displayTitle },
152+
})
153+
const expected = { data: { displayTitle: 'Waiting for Review report validation + 1' } }
154+
expect(waitTitle(blocks)).toMatchObject(expected)
155+
expect(waitTitle(modelToContentBlocks(contentBlocksToModel(blocks)))).toMatchObject(expected)
156+
const saved: PersistedMessage = {
157+
id: 'message',
158+
role: 'assistant',
159+
content: '',
160+
timestamp: new Date(0).toISOString(),
161+
contentBlocks: blocks
162+
.filter((block) => block.toolCall)
163+
.map((block) => ({
164+
type: 'tool',
165+
phase: 'call',
166+
toolCall: {
167+
id: block.toolCall!.id,
168+
name: block.toolCall!.name,
169+
state: block.toolCall!.status,
170+
params: block.toolCall!.params,
171+
result: block.toolCall!.result,
172+
display: { title: block.toolCall!.displayTitle },
173+
},
174+
...(spans ? { spanId: 'main' } : {}),
175+
})),
176+
}
177+
expect(
178+
waitTitle(toDisplayMessage(stripToolResultOutput(saved)).contentBlocks ?? [])
179+
).toMatchObject(expected)
180+
expect(blocks).toEqual(original)
181+
expect(waitTitle([wait])).toMatchObject({
182+
data: { displayTitle: wait.toolCall?.displayTitle },
183+
})
184+
}
185+
)
186+
187+
it('ignores unrelated, failed, malformed and unnamed launch results', () => {
188+
for (const patch of [
189+
{ name: 'call_integration_tool' },
190+
{ result: { success: false, output: launch.toolCall?.result?.output } },
191+
{ result: { success: true, output: { async: true, agentId, name: displayName } } },
192+
{
193+
result: { success: true, output: { async: true, status: 'launched', agentId, name: ' ' } },
194+
},
195+
{ result: { success: true, output: null } },
196+
]) {
197+
const invalid = structuredClone(launch)
198+
Object.assign(invalid.toolCall!, patch)
199+
expect(waitTitle([invalid, wait])).toMatchObject({
200+
data: { displayTitle: wait.toolCall?.displayTitle },
201+
})
202+
}
203+
})
204+
})
205+
104206
describe('getOrchestratorMessageText', () => {
105207
it('copies only orchestrator text from span-based messages', () => {
106208
const blocks: ContentBlock[] = [

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

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useState,
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
14+
import { compactAsyncAgentLaunch } from '@/lib/copilot/chat/async-agent-display'
1415
import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
1516
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
1617
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
@@ -181,7 +182,19 @@ function mapToolStatusToClientState(
181182
}
182183
}
183184

184-
function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): string | undefined {
185+
function getOverrideDisplayTitle(
186+
tc: NonNullable<ContentBlock['toolCall']>,
187+
agentNames: ReadonlyMap<string, string>
188+
): string | undefined {
189+
if (
190+
agentNames.size > 0 &&
191+
['wait_agents', 'tail_agent', 'steer_agent', 'interrupt_agent'].includes(tc.name)
192+
) {
193+
const ids = tc.name === 'wait_agents' ? tc.params?.agent_ids : [tc.params?.agent_id]
194+
if (Array.isArray(ids) && ids.some((id) => typeof id === 'string' && agentNames.has(id))) {
195+
return getToolDisplayTitle(tc.name, tc.params, agentNames)
196+
}
197+
}
185198
if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) {
186199
return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text
187200
}
@@ -199,8 +212,11 @@ function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): str
199212
return undefined
200213
}
201214

202-
function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
203-
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
215+
function toToolData(
216+
tc: NonNullable<ContentBlock['toolCall']>,
217+
agentNames: ReadonlyMap<string, string>
218+
): ToolCallData {
219+
const overrideDisplayTitle = getOverrideDisplayTitle(tc, agentNames)
204220
const resolvedTitle =
205221
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
206222
const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status, tc.name)
@@ -253,7 +269,10 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
253269
* no name/tool-call reverse lookups. Delegation tool_calls are absorbed — the
254270
* subagent span is the canonical representation of the nested agent.
255271
*/
256-
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
272+
function parseBlocksWithSpanTree(
273+
blocks: ContentBlock[],
274+
agentNames: ReadonlyMap<string, string>
275+
): MessageSegment[] {
257276
const segments: MessageSegment[] = []
258277
const groupsBySpanId = new Map<string, AgentGroupSegment>()
259278
// Stable per-run counters for React keys. The Nth top-level text run / Nth
@@ -422,7 +441,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
422441
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
423442
// Delegation tools are represented by their subagent span group; absorb.
424443
if (SUBAGENT_KEYS.has(tc.name)) continue
425-
const tool = toToolData(tc)
444+
const tool = toToolData(tc, agentNames)
426445
if (block.spanId) {
427446
let g = groupsBySpanId.get(block.spanId)
428447
// Out-of-order safety: a subagent's tool can stream before its
@@ -500,10 +519,19 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
500519
* span identity existed.
501520
*/
502521
export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
522+
/** Launch results retain display names; their slugified IDs can cut words short. */
523+
const agentNames = new Map<string, string>()
524+
for (const block of blocks) {
525+
if (block.type !== 'tool_call') continue
526+
const tc = block.toolCall
527+
if (!tc?.result?.success) continue
528+
const launch = compactAsyncAgentLaunch(tc.name, tc.result.output)
529+
if (launch) agentNames.set(launch.agentId, launch.name)
530+
}
503531
if (blocks.some((block) => Boolean(block.spanId))) {
504-
return parseBlocksWithSpanTree(blocks)
532+
return parseBlocksWithSpanTree(blocks, agentNames)
505533
}
506-
return parseBlocksLegacy(blocks)
534+
return parseBlocksLegacy(blocks, agentNames)
507535
}
508536

509537
function joinRenderableText(parts: string[]): string {
@@ -523,7 +551,10 @@ export function getOrchestratorMessageText(
523551
)
524552
}
525553

526-
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
554+
function parseBlocksLegacy(
555+
blocks: ContentBlock[],
556+
agentNames: ReadonlyMap<string, string>
557+
): MessageSegment[] {
527558
const segments: MessageSegment[] = []
528559
const groupsByKey = new Map<string, AgentGroupSegment>()
529560
let activeGroupKey: string | null = null
@@ -677,7 +708,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
677708
continue
678709
}
679710

680-
const tool = toToolData(tc)
711+
const tool = toToolData(tc, agentNames)
681712

682713
if (tc.calledBy) {
683714
const { group: g, created } = ensureGroup(tc.calledBy, block.parentToolCallId)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { isPlainRecord } from '@sim/utils/object'
2+
import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1'
3+
4+
/** Retains only bounded launch identity for labels, never the agent's task or result. */
5+
export function compactAsyncAgentLaunch(toolName: string, output: unknown) {
6+
if (
7+
TOOL_CATALOG[toolName]?.route !== 'subagent' ||
8+
!isPlainRecord(output) ||
9+
output.async !== true ||
10+
output.status !== 'launched' ||
11+
typeof output.agentId !== 'string' ||
12+
!output.agentId ||
13+
output.agentId.length > 128 ||
14+
typeof output.name !== 'string' ||
15+
!output.name.trim() ||
16+
output.name.length > 256
17+
) {
18+
return undefined
19+
}
20+
return { async: true, status: 'launched', agentId: output.agentId, name: output.name }
21+
}

apps/sim/lib/copilot/chat/persisted-message.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,51 @@ describe('persisted-message', () => {
380380
})
381381

382382
describe('stripToolResultOutput', () => {
383+
it('keeps only bounded successful async launch identity for display', () => {
384+
const launch = {
385+
async: true,
386+
status: 'launched',
387+
agentId: 'review-report-1',
388+
name: 'Review report',
389+
}
390+
const message: PersistedMessage = {
391+
id: 'message',
392+
role: 'assistant',
393+
content: '',
394+
timestamp: new Date(0).toISOString(),
395+
contentBlocks: [
396+
{
397+
type: 'tool',
398+
phase: 'call',
399+
toolCall: {
400+
id: 'launch',
401+
name: 'workflow',
402+
state: 'success',
403+
result: {
404+
success: true,
405+
output: { ...launch, note: 'large content', task: 'private task' },
406+
},
407+
},
408+
},
409+
],
410+
}
411+
expect(stripToolResultOutput(message).contentBlocks?.[0].toolCall?.result).toEqual({
412+
success: true,
413+
output: launch,
414+
})
415+
for (const output of [
416+
{ ...launch, agentId: 'x'.repeat(129) },
417+
{ ...launch, name: 'x'.repeat(257) },
418+
{ ...launch, async: false },
419+
]) {
420+
const invalid = structuredClone(message)
421+
invalid.contentBlocks![0].toolCall!.result!.output = output
422+
expect(stripToolResultOutput(invalid).contentBlocks?.[0].toolCall?.result).toEqual({
423+
success: true,
424+
})
425+
}
426+
})
427+
383428
it('drops result.output but keeps success and error', () => {
384429
const message: PersistedMessage = {
385430
id: 'msg-1',

apps/sim/lib/copilot/chat/persisted-message.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateId } from '@sim/utils/id'
22
import { isPlainRecord } from '@sim/utils/object'
3+
import { compactAsyncAgentLaunch } from '@/lib/copilot/chat/async-agent-display'
34
import { compactRetrievalCitations } from '@/lib/copilot/chat/retrieval-citations'
45
import {
56
mergeAndRedactPersistedBlocks,
@@ -132,9 +133,9 @@ export interface PersistedMessage {
132133
}
133134

134135
/**
135-
* Drop persisted tool outputs, keeping `success` and `error`. The one narrow
136-
* UI-state exceptions are bounded retrieval citations and a browser takeover's user-authored instruction, which
137-
* restores its answered question recap after reload. Other outputs are never
136+
* Drop persisted tool outputs, keeping `success` and `error`. Narrow UI-state
137+
* exceptions retain bounded retrieval citations, async agent launch names, and
138+
* a browser takeover's user-authored instruction for display after reload. Other outputs are never
138139
* rendered or replayed to the model (the upstream service owns conversation
139140
* memory), so storing them only bloats
140141
* `copilot_messages.content` — a single `get_workflow_logs`/`run_workflow`
@@ -154,6 +155,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa
154155
if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block
155156
const output = result.output
156157
const citations = result.success ? compactRetrievalCitations(toolCall.name, output) : undefined
158+
const agentLaunch = result.success ? compactAsyncAgentLaunch(toolCall.name, output) : undefined
157159
const userInstruction =
158160
toolCall.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && isPlainRecord(output)
159161
? output.userInstruction
@@ -171,6 +173,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa
171173
const strippedResult: { success: boolean; output?: unknown; error?: string } = {
172174
success: result.success,
173175
...(citations ? { output: citations } : {}),
176+
...(agentLaunch ? { output: agentLaunch } : {}),
174177
...(normalizedInstruction ? { output: { userInstruction: normalizedInstruction } } : {}),
175178
}
176179
if (result.error !== undefined) strippedResult.error = result.error

apps/sim/lib/copilot/tools/tool-display.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,34 @@ import {
2222
mvDisplayVerb,
2323
} from '@/lib/copilot/tools/tool-display'
2424

25+
describe('async agent titles', () => {
26+
const id = 'review-report-validatio-1'
27+
const names = new Map([[id, 'Review report validation']])
28+
29+
it('preserves display names, wait modes, counts and unknown-ID fallbacks', () => {
30+
expect(getToolDisplayTitle('wait_agents', { agent_ids: [id] }, names)).toBe(
31+
'Waiting for Review report validation'
32+
)
33+
expect(
34+
getToolDisplayTitle('wait_agents', { agent_ids: [id, 'other-agent-2'], mode: 'any' }, names)
35+
).toBe('Waiting for the first of Review report validation + 1')
36+
expect(getToolDisplayTitle('wait_agents', { agent_ids: ['other-agent-2'] }, names)).toBe(
37+
'Waiting for Other Agent'
38+
)
39+
expect(getToolDisplayTitle('wait_agents', { agent_ids: [] }, names)).toBe('Waiting for agents')
40+
})
41+
42+
it.each([
43+
['tail_agent', 'Checking on'],
44+
['steer_agent', 'Steering'],
45+
['interrupt_agent', 'Stopping'],
46+
])('uses the same display name for %s', (tool, verb) => {
47+
expect(getToolDisplayTitle(tool, { agent_id: id }, names)).toBe(
48+
`${verb} Review report validation`
49+
)
50+
})
51+
})
52+
2553
function representativeToolArgs(entry: ToolCatalogEntry): Record<string, unknown> {
2654
const args: Record<string, unknown> = {}
2755
if (!entry.parameters || typeof entry.parameters !== 'object') return args

0 commit comments

Comments
 (0)