Skip to content

Commit f261cfe

Browse files
committed
Display names for reads
1 parent 04ca645 commit f261cfe

4 files changed

Lines changed: 185 additions & 24 deletions

File tree

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ function subagentToolCall(
2323
}
2424
}
2525

26+
function mainText(content: string): ContentBlock {
27+
return { type: 'text', content, timestamp: 1 }
28+
}
29+
30+
function mainToolCall(id: string, name: string): ContentBlock {
31+
return { type: 'tool_call', toolCall: { id, name, status: 'success' }, timestamp: 1 }
32+
}
33+
2634
describe('parseBlocks span-identity tree', () => {
2735
it('nests a deploy subagent inside the workflow subagent that spawned it', () => {
2836
const blocks: ContentBlock[] = [
@@ -101,6 +109,61 @@ describe('parseBlocks span-identity tree', () => {
101109
expect(segments[0].items.some((item) => item.type === 'tool')).toBe(true)
102110
})
103111

112+
it('interleaves mothership tools with main text instead of clustering them at the top', () => {
113+
const blocks: ContentBlock[] = [
114+
mainText('Let me search.'),
115+
mainToolCall('t1', 'grep'),
116+
subagentStart('research', 'S1', 'main'),
117+
{ type: 'subagent_text', content: 'looking', spanId: 'S1', timestamp: 2 },
118+
{ type: 'subagent_end', spanId: 'S1', parentSpanId: 'main', timestamp: 3 },
119+
mainText('Found it, now finding files.'),
120+
mainToolCall('t2', 'glob'),
121+
]
122+
123+
const segments = parseBlocks(blocks)
124+
125+
// Order is preserved chronologically: the second mothership tool stays below
126+
// the research subagent and the trailing text rather than jumping back up
127+
// into the first group.
128+
const shape = segments.map((s) => (s.type === 'agent_group' ? s.agentName : s.type))
129+
expect(shape).toEqual(['text', 'mothership', 'research', 'text', 'mothership'])
130+
131+
// The two mothership tools land in two distinct groups, one each.
132+
const mothershipGroups = segments.filter(
133+
(s) => s.type === 'agent_group' && s.agentName === 'mothership'
134+
)
135+
expect(mothershipGroups).toHaveLength(2)
136+
const [first, second] = mothershipGroups
137+
if (first.type !== 'agent_group' || second.type !== 'agent_group') {
138+
throw new Error('expected mothership groups')
139+
}
140+
expect(first.items).toHaveLength(1)
141+
expect(second.items).toHaveLength(1)
142+
expect(first.items[0].type === 'tool' && first.items[0].data.toolName).toBe('grep')
143+
expect(second.items[0].type === 'tool' && second.items[0].data.toolName).toBe('glob')
144+
})
145+
146+
it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => {
147+
const blocks: ContentBlock[] = [
148+
subagentStart('workflow', 'S1', 'main'),
149+
subagentToolCall('t1', 'workspace_file', 'S1', 'workflow'),
150+
{ type: 'subagent', content: 'file', spanId: 'S2', parentSpanId: 'S1', timestamp: 2 },
151+
{ type: 'subagent_text', content: 'writing', spanId: 'S2', timestamp: 3 },
152+
]
153+
154+
const segments = parseBlocks(blocks)
155+
expect(segments).toHaveLength(1)
156+
const workflow = segments[0]
157+
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
158+
159+
// The workspace_file dispatch tool is absorbed (not shown as a sibling tool);
160+
// only the nested file subagent remains under workflow.
161+
expect(workflow.items.some((item) => item.type === 'tool')).toBe(false)
162+
const nested = workflow.items.find((item) => item.type === 'agent_group')
163+
if (!nested || nested.type !== 'agent_group') throw new Error('expected nested file group')
164+
expect(nested.group.agentName).toBe('file')
165+
})
166+
104167
it('falls back to legacy flat grouping when blocks have no span identity', () => {
105168
const blocks: ContentBlock[] = [
106169
{ type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 },

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,39 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
184184
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
185185
const segments: MessageSegment[] = []
186186
const groupsBySpanId = new Map<string, AgentGroupSegment>()
187-
let mothership: AgentGroupSegment | null = null
188187

188+
const tailMothershipGroup = (): AgentGroupSegment | null => {
189+
const last = segments[segments.length - 1]
190+
return last?.type === 'agent_group' && last.agentName === 'mothership' ? last : null
191+
}
192+
193+
// Top-level (mothership) tool calls render in a collapsible group. Reuse that
194+
// group only while it is still the most recent segment so consecutive tools
195+
// stay together; once any other segment (main text, a spawned subagent,
196+
// thinking, etc.) breaks the run, the next tool opens a fresh group below it
197+
// instead of jumping back up into the original one. This keeps the mothership's
198+
// tools and prose interleaved in the order they actually happened.
189199
const ensureMothership = (): AgentGroupSegment => {
190-
if (!mothership) {
191-
mothership = createAgentGroupSegment('mothership', 'mothership', segments.length)
192-
segments.push(mothership)
200+
const existing = tailMothershipGroup()
201+
if (existing) return existing
202+
const group = createAgentGroupSegment('mothership', 'mothership', segments.length)
203+
segments.push(group)
204+
return group
205+
}
206+
207+
// When a subagent spawns, drop the dispatch tool that triggered it (e.g.
208+
// workspace_file -> file) from whichever container it landed in so it does not
209+
// render as a separate entry beside the agent group.
210+
const absorbDispatchTool = (toolName: string, parentSpanId: string | undefined): void => {
211+
const container =
212+
parentSpanId && parentSpanId !== SPAN_ROOT
213+
? groupsBySpanId.get(parentSpanId)
214+
: tailMothershipGroup()
215+
if (!container) return
216+
const last = container.items[container.items.length - 1]
217+
if (last?.type === 'tool' && last.data.toolName === toolName) {
218+
container.items.pop()
193219
}
194-
return mothership
195220
}
196221

197222
const attachSpanGroup = (group: AgentGroupSegment, parentSpanId: string | undefined): void => {
@@ -283,14 +308,9 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
283308
if (block.type === 'subagent') {
284309
if (!block.content || !block.spanId) continue
285310
// Absorb a trailing dispatch tool (e.g. workspace_file -> file) so it does
286-
// not render as a separate Mothership entry alongside the agent group.
311+
// not render as a separate entry alongside the agent group.
287312
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content]
288-
if (dispatchToolName && mothership) {
289-
const last = mothership.items[mothership.items.length - 1]
290-
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
291-
mothership.items.pop()
292-
}
293-
}
313+
if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId)
294314
const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId, i)
295315
// Show the working/delegating spinner from span open until the agent
296316
// emits its first content or tool (or ends). The legacy path derived this

apps/sim/lib/copilot/tools/client/store-utils.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,63 @@ describe('resolveToolDisplay', () => {
3737
).toBe('Read RET XYZ')
3838
})
3939

40+
it('decodes percent-encoded VFS path segments for display', () => {
41+
expect(
42+
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
43+
path: 'files/My%20Report.txt',
44+
})?.text
45+
).toBe('Reading My Report.txt')
46+
47+
expect(
48+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
49+
path: 'workflows/My%20Workflow/meta.json',
50+
})?.text
51+
).toBe('Read My Workflow')
52+
53+
expect(
54+
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
55+
path: 'files/caf%C3%A9.txt',
56+
})?.text
57+
).toBe('Reading café.txt')
58+
59+
expect(
60+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
61+
path: 'files/Quarterly%20Report.docx/content',
62+
})?.text
63+
).toBe('Read Quarterly Report.docx')
64+
})
65+
66+
it('shows only the file name for file reads, dropping the folder path and content qualifier', () => {
67+
// Bare file leaf inside a folder → just the file name (with extension).
68+
expect(
69+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
70+
path: 'files/Skills/Skill%20%E2%80%94%20PostHog%20Analytics.md',
71+
})?.text
72+
).toBe('Read Skill — PostHog Analytics.md')
73+
74+
// Explicit content facet → no "the content of", folder dropped too.
75+
expect(
76+
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
77+
path: 'files/Skills/Skill%20%E2%80%94%20PostHog%20Analytics.md/content',
78+
})?.text
79+
).toBe('Read Skill — PostHog Analytics.md')
80+
81+
// Non-content facets keep their descriptive label but still show only the name.
82+
expect(
83+
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
84+
path: 'files/Reports/brief.docx/meta.json',
85+
})?.text
86+
).toBe('Reading metadata for brief.docx')
87+
})
88+
89+
it('falls back to the raw segment when it is not valid percent-encoding', () => {
90+
expect(
91+
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
92+
path: 'files/100%done.txt',
93+
})?.text
94+
).toBe('Reading 100%done.txt')
95+
})
96+
4097
it('formats special workspace file reads as natural language', () => {
4198
expect(
4299
resolveToolDisplay(ReadTool.id, ClientToolCallState.error, {
@@ -48,7 +105,7 @@ describe('resolveToolDisplay', () => {
48105
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
49106
path: 'files/Reports/brief.docx/content',
50107
})?.text
51-
).toBe('Read the content of Reports/brief.docx')
108+
).toBe('Read brief.docx')
52109

53110
expect(
54111
resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {

apps/sim/lib/copilot/tools/client/store-utils.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
55
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
66
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
77
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
8+
import { decodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
89

910
/** Respond tools are internal handoff tools shown with a friendly generic label. */
1011
const HIDDEN_TOOL_SUFFIX = '_respond'
@@ -80,13 +81,28 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta
8081
}
8182
}
8283

84+
/**
85+
* VFS paths store each segment percent-encoded (see {@link encodeVfsSegment}), so
86+
* a read on "My Report.txt" arrives as "files/My%20Report.txt". Decode for
87+
* display so the user sees the real file name. Falls back to the raw segment when
88+
* it is not valid encoding (e.g. a literal "%" that was never encoded).
89+
*/
90+
function decodeVfsSegmentSafe(segment: string): string {
91+
try {
92+
return decodeVfsSegment(segment)
93+
} catch {
94+
return segment
95+
}
96+
}
97+
8398
function describeReadTarget(path: string | undefined): string | undefined {
8499
if (!path) return undefined
85100

86101
const segments = path
87102
.split('/')
88103
.map((segment) => segment.trim())
89104
.filter(Boolean)
105+
.map(decodeVfsSegmentSafe)
90106

91107
if (segments.length === 0) return undefined
92108

@@ -107,25 +123,30 @@ function describeReadTarget(path: string | undefined): string | undefined {
107123
return stripExtension(resourceName)
108124
}
109125

110-
const FILE_SPECIAL_READ_TARGET_PREFIXES: Record<string, string> = {
111-
content: 'the content of',
126+
// A workspace file is addressed as a directory of facets in the VFS
127+
// (files/{...path}/{name}/{facet}). `content` is the default facet — reading a
128+
// file means reading its content — so it carries no qualifier, matching a bare
129+
// `files/{...path}/{name}` read. The remaining facets are genuinely distinct, so
130+
// they keep a descriptive label.
131+
const FILE_FACET_LABELS: Record<string, string> = {
132+
content: '',
112133
'meta.json': 'metadata for',
113134
style: 'style details for',
114135
'compiled-check': 'the final file check for',
115136
}
116137

117138
function describeFileReadTarget(segments: string[]): string {
118139
const lastSegment = segments[segments.length - 1] || ''
119-
const specialPrefix = FILE_SPECIAL_READ_TARGET_PREFIXES[lastSegment]
120-
if (specialPrefix) {
121-
return `${specialPrefix} ${describeSpecialFilePathSubject(segments)}`
140+
const facetLabel = FILE_FACET_LABELS[lastSegment]
141+
// Treat the suffix as a facet only when a real file name precedes it; otherwise
142+
// the leaf is the file itself (e.g. a file literally named "content").
143+
if (facetLabel !== undefined && segments.length > 2) {
144+
const fileName = segments[segments.length - 2]
145+
return facetLabel ? `${facetLabel} ${fileName}` : fileName
122146
}
123-
124-
return segments.slice(1).join('/') || lastSegment
125-
}
126-
127-
function describeSpecialFilePathSubject(segments: string[]): string {
128-
return segments.slice(1, -1).join('/') || 'this file'
147+
// Show just the file name, not the folder path — these are glanceable status
148+
// lines, and the other resource types already render the leaf only.
149+
return lastSegment
129150
}
130151

131152
function getLeafResourceSegment(segments: string[]): string {

0 commit comments

Comments
 (0)