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 @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -181,7 +182,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 @@ -199,8 +212,11 @@ function getOverrideDisplayTitle(tc: NonNullable<ContentBlock['toolCall']>): str
return undefined
}

function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
function toToolData(
tc: NonNullable<ContentBlock['toolCall']>,
agentNames: ReadonlyMap<string, string>
): ToolCallData {
const overrideDisplayTitle = getOverrideDisplayTitle(tc, agentNames)
const resolvedTitle =
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status, tc.name)
Expand Down Expand Up @@ -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<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 @@ -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
Expand Down Expand Up @@ -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<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.
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 {
Expand All @@ -523,7 +551,10 @@ export function getOrchestratorMessageText(
)
}

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 @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/copilot/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/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 }
}
45 changes: 45 additions & 0 deletions apps/sim/lib/copilot/chat/persisted-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 6 additions & 3 deletions apps/sim/lib/copilot/chat/persisted-message.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions apps/sim/lib/copilot/tools/tool-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
const args: Record<string, unknown> = {}
if (!entry.parameters || typeof entry.parameters !== 'object') return args
Expand Down
Loading
Loading