Skip to content

Commit df15ebd

Browse files
committed
Fix file read and tool call name persistence bug
1 parent b2b10e2 commit df15ebd

File tree

10 files changed

+150
-19
lines changed

10 files changed

+150
-19
lines changed

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2507,6 +2507,14 @@ export function useChat(
25072507
if (block.type === 'tool_call' && block.toolCall) {
25082508
const isCancelled =
25092509
block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled'
2510+
const displayTitle = isCancelled ? 'Stopped by user' : block.toolCall.displayTitle
2511+
const display =
2512+
displayTitle || block.toolCall.phaseLabel
2513+
? {
2514+
...(displayTitle ? { title: displayTitle } : {}),
2515+
...(block.toolCall.phaseLabel ? { phaseLabel: block.toolCall.phaseLabel } : {}),
2516+
}
2517+
: undefined
25102518
return {
25112519
type: block.type,
25122520
content: block.content,
@@ -2516,9 +2524,7 @@ export function useChat(
25162524
state: isCancelled ? MothershipStreamV1ToolOutcome.cancelled : block.toolCall.status,
25172525
params: block.toolCall.params,
25182526
result: block.toolCall.result,
2519-
display: {
2520-
text: isCancelled ? 'Stopped by user' : block.toolCall.displayTitle,
2521-
},
2527+
...(display ? { display } : {}),
25222528
calledBy: block.toolCall.calledBy,
25232529
},
25242530
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ describe('persisted-message', () => {
2525
id: 'tool-1',
2626
name: 'read',
2727
status: 'success',
28+
displayTitle: 'Reading foo.txt',
29+
phaseLabel: 'Workspace',
2830
params: { path: 'foo.txt' },
2931
result: { success: true, output: { ok: true } },
3032
},
@@ -44,6 +46,7 @@ describe('persisted-message', () => {
4446
id: 'tool-1',
4547
name: 'read',
4648
state: 'success',
49+
display: { title: 'Reading foo.txt', phaseLabel: 'Workspace' },
4750
params: { path: 'foo.txt' },
4851
result: { success: true, output: { ok: true } },
4952
calledBy: 'workflow',
@@ -70,7 +73,7 @@ describe('persisted-message', () => {
7073
id: 'tool-1',
7174
name: 'read',
7275
state: 'cancelled',
73-
display: { text: 'Stopped by user' },
76+
display: { title: 'Stopped by user', phaseLabel: 'Workspace' },
7477
},
7578
},
7679
],
@@ -92,7 +95,7 @@ describe('persisted-message', () => {
9295
id: 'tool-1',
9396
name: 'read',
9497
state: 'cancelled',
95-
display: { title: 'Stopped by user' },
98+
display: { title: 'Stopped by user', phaseLabel: 'Workspace' },
9699
},
97100
},
98101
{

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ function mapContentBlock(block: ContentBlock): PersistedContentBlock {
146146
? { params: block.toolCall.params }
147147
: {}),
148148
...(block.calledBy ? { calledBy: block.calledBy } : {}),
149+
...(block.toolCall.displayTitle || block.toolCall.phaseLabel
150+
? {
151+
display: {
152+
...(block.toolCall.displayTitle ? { title: block.toolCall.displayTitle } : {}),
153+
...(block.toolCall.phaseLabel ? { phaseLabel: block.toolCall.phaseLabel } : {}),
154+
},
155+
}
156+
: {}),
149157
}
150158

151159
return {
@@ -326,7 +334,14 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock {
326334
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
327335
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
328336
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),
329-
...(block.toolCall.display ? { display: { title: block.toolCall.display.text } } : {}),
337+
...(block.toolCall.display
338+
? {
339+
display: {
340+
title: block.toolCall.display.title ?? block.toolCall.display.text,
341+
phaseLabel: block.toolCall.display.phaseLabel,
342+
},
343+
}
344+
: {}),
330345
},
331346
}
332347
}

apps/sim/lib/copilot/request/handlers/handlers.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ describe('sse-handlers tool lifecycle', () => {
102102
executor: MothershipStreamV1ToolExecutor.sim,
103103
mode: MothershipStreamV1ToolMode.async,
104104
phase: MothershipStreamV1ToolPhase.call,
105+
ui: {
106+
title: 'Reading foo.txt',
107+
phaseLabel: 'Workspace',
108+
},
105109
},
106110
} satisfies StreamEvent,
107111
context,
@@ -127,7 +131,19 @@ describe('sse-handlers tool lifecycle', () => {
127131

128132
const updated = context.toolCalls.get('tool-1')
129133
expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success)
134+
expect(updated?.displayTitle).toBe('Reading foo.txt')
135+
expect(updated?.phaseLabel).toBe('Workspace')
130136
expect(updated?.result?.output).toEqual({ ok: true })
137+
expect(context.contentBlocks.at(0)).toEqual(
138+
expect.objectContaining({
139+
type: 'tool_call',
140+
toolCall: expect.objectContaining({
141+
id: 'tool-1',
142+
displayTitle: 'Reading foo.txt',
143+
phaseLabel: 'Workspace',
144+
}),
145+
})
146+
)
131147
})
132148

133149
it('preserves primitive tool outputs through async completion persistence', async () => {

apps/sim/lib/copilot/request/handlers/tool.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ import {
4242

4343
const logger = createLogger('CopilotToolHandler')
4444

45+
function applyToolDisplay(
46+
toolCall: ToolCallState | undefined,
47+
ui: { title?: string; phaseLabel?: string }
48+
): void {
49+
if (!toolCall) return
50+
if (ui.title) toolCall.displayTitle = ui.title
51+
if (ui.phaseLabel) toolCall.phaseLabel = ui.phaseLabel
52+
}
53+
4554
/**
4655
* Unified tool event handler for both main and subagent scopes.
4756
*
@@ -148,11 +157,13 @@ async function handleCallPhase(
148157
const isPartial = data.partial === true || isGenerating
149158
const existing = context.toolCalls.get(toolCallId)
150159
const isSubagent = scope === 'subagent'
160+
const ui = getToolCallUI(data)
151161

152162
if (isSubagent) {
153163
if (wasToolResultSeen(toolCallId) || existing?.endTime) {
154164
if (existing && !existing.name && toolName) existing.name = toolName
155165
if (existing && !existing.params && args) existing.params = args
166+
applyToolDisplay(existing, ui)
156167
return
157168
}
158169
} else {
@@ -162,14 +173,15 @@ async function handleCallPhase(
162173
) {
163174
if (!existing.name && toolName) existing.name = toolName
164175
if (!existing.params && args) existing.params = args
176+
applyToolDisplay(existing, ui)
165177
return
166178
}
167179
}
168180

169181
if (isSubagent) {
170-
registerSubagentToolCall(context, toolCallId, toolName, args, parentToolCallId!)
182+
registerSubagentToolCall(context, toolCallId, toolName, args, parentToolCallId!, ui)
171183
} else {
172-
registerMainToolCall(context, toolCallId, toolName, args, existing)
184+
registerMainToolCall(context, toolCallId, toolName, args, existing, ui)
173185
}
174186

175187
if (isPartial) return
@@ -184,7 +196,7 @@ async function handleCallPhase(
184196
const readPath = typeof args?.path === 'string' ? args.path : undefined
185197
if (toolName === 'read' && readPath?.startsWith('internal/')) return
186198

187-
const { clientExecutable, simExecutable, internal } = getToolCallUI(data)
199+
const { clientExecutable, simExecutable, internal } = ui
188200
const catalogEntry = getToolEntry(toolName)
189201
const isInternal = internal || catalogEntry?.internal === true
190202
const staticSimExecuted = isSimExecuted(toolName)
@@ -225,7 +237,8 @@ function registerSubagentToolCall(
225237
toolCallId: string,
226238
toolName: string,
227239
args: Record<string, unknown> | undefined,
228-
parentToolCallId: string
240+
parentToolCallId: string,
241+
ui: { title?: string; phaseLabel?: string }
229242
): void {
230243
if (!context.subAgentToolCalls[parentToolCallId]) {
231244
context.subAgentToolCalls[parentToolCallId] = []
@@ -235,6 +248,7 @@ function registerSubagentToolCall(
235248
if (toolCall) {
236249
if (!toolCall.name && toolName) toolCall.name = toolName
237250
if (args && !toolCall.params) toolCall.params = args
251+
applyToolDisplay(toolCall, ui)
238252
} else {
239253
toolCall = {
240254
id: toolCallId,
@@ -243,6 +257,7 @@ function registerSubagentToolCall(
243257
params: args,
244258
startTime: Date.now(),
245259
}
260+
applyToolDisplay(toolCall, ui)
246261
context.toolCalls.set(toolCallId, toolCall)
247262
const parentToolCall = context.toolCalls.get(parentToolCallId)
248263
if (!hideFromUi) {
@@ -259,6 +274,7 @@ function registerSubagentToolCall(
259274
if (existingSubagentToolCall) {
260275
if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName
261276
if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args
277+
applyToolDisplay(existingSubagentToolCall, ui)
262278
} else {
263279
subagentToolCalls.push(toolCall)
264280
}
@@ -269,11 +285,13 @@ function registerMainToolCall(
269285
toolCallId: string,
270286
toolName: string,
271287
args: Record<string, unknown> | undefined,
272-
existing: ToolCallState | undefined
288+
existing: ToolCallState | undefined,
289+
ui: { title?: string; phaseLabel?: string }
273290
): void {
274291
const hideFromUi = isToolHiddenInUi(toolName)
275292
if (existing) {
276293
if (args && !existing.params) existing.params = args
294+
applyToolDisplay(existing, ui)
277295
if (
278296
!hideFromUi &&
279297
!context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId)
@@ -288,6 +306,7 @@ function registerMainToolCall(
288306
params: args,
289307
startTime: Date.now(),
290308
}
309+
applyToolDisplay(created, ui)
291310
context.toolCalls.set(toolCallId, created)
292311
if (!hideFromUi) {
293312
addContentBlock(context, { type: 'tool_call', toolCall: created })

apps/sim/lib/copilot/request/handlers/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): {
129129
simExecutable: boolean
130130
internal: boolean
131131
hidden: boolean
132+
title?: string
133+
phaseLabel?: string
132134
} {
133135
const raw = asRecord(data.ui)
134136
return {
@@ -138,6 +140,8 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): {
138140
simExecutable: data.executor === MothershipStreamV1ToolExecutor.sim,
139141
internal: raw.internal === true,
140142
hidden: raw.hidden === true,
143+
title: typeof raw.title === 'string' ? raw.title : undefined,
144+
phaseLabel: typeof raw.phaseLabel === 'string' ? raw.phaseLabel : undefined,
141145
}
142146
}
143147

apps/sim/lib/copilot/request/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface ToolCallState {
2121
id: string
2222
name: string
2323
status: ToolCallStatus
24+
displayTitle?: string
25+
phaseLabel?: string
2426
params?: Record<string, unknown>
2527
result?: ToolCallStateResult
2628
error?: string

apps/sim/lib/copilot/vfs/workspace-vfs.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,15 +454,18 @@ export class WorkspaceVFS {
454454
const activeMatch = path.match(/^files\/(.+?)(?:\/content)?$/)
455455
const match = deletedMatch || activeMatch
456456
if (!match) return null
457-
const fileName = match[1]
457+
const fileReference = path
458+
.replace(/^recently-deleted\//, '')
459+
.replace(/\/content$/, '')
460+
.replace(/^\/+/, '')
458461

459-
if (fileName.endsWith('/meta.json') || path.endsWith('/meta.json')) return null
462+
if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null
460463

461464
const scope = deletedMatch ? 'archived' : 'active'
462465

463466
try {
464467
const files = await listWorkspaceFiles(this._workspaceId, { scope })
465-
const record = findWorkspaceFileRecord(files, fileName)
468+
const record = findWorkspaceFileRecord(files, fileReference)
466469
if (!record) return null
467470
return readFileRecord(record)
468471
} catch (err) {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import {
7+
findWorkspaceFileRecord,
8+
normalizeWorkspaceFileReference,
9+
type WorkspaceFileRecord,
10+
} from './workspace-file-manager'
11+
12+
const FILE_ID = 'ec28e5d5-898a-48f0-aa6f-2fd7427c9563'
13+
14+
function makeFileRecord(): WorkspaceFileRecord {
15+
return {
16+
id: FILE_ID,
17+
workspaceId: 'ws_123',
18+
name: 'the_last_cartographer_of_vael.md',
19+
key: 'workspace/ws_123/mock-key',
20+
path: '/api/files/serve/mock-key?context=workspace',
21+
size: 128,
22+
type: 'text/markdown',
23+
uploadedBy: 'user_123',
24+
uploadedAt: new Date('2026-04-13T00:00:00.000Z'),
25+
}
26+
}
27+
28+
describe('workspace file reference normalization', () => {
29+
it('normalizes canonical by-id VFS paths to the raw file id', () => {
30+
expect(normalizeWorkspaceFileReference(`files/by-id/${FILE_ID}/content`)).toBe(FILE_ID)
31+
expect(normalizeWorkspaceFileReference(`files/by-id/${FILE_ID}/meta.json`)).toBe(FILE_ID)
32+
expect(normalizeWorkspaceFileReference(`by-id/${FILE_ID}`)).toBe(FILE_ID)
33+
expect(normalizeWorkspaceFileReference(`recently-deleted/files/by-id/${FILE_ID}/content`)).toBe(
34+
FILE_ID
35+
)
36+
})
37+
38+
it('finds files from canonical by-id content paths', () => {
39+
const files = [makeFileRecord()]
40+
41+
expect(findWorkspaceFileRecord(files, `files/by-id/${FILE_ID}/content`)).toMatchObject({
42+
id: FILE_ID,
43+
name: 'the_last_cartographer_of_vael.md',
44+
})
45+
46+
expect(findWorkspaceFileRecord(files, `by-id/${FILE_ID}`)).toMatchObject({
47+
id: FILE_ID,
48+
name: 'the_last_cartographer_of_vael.md',
49+
})
50+
})
51+
})

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,29 @@ export async function listWorkspaceFiles(
437437
*/
438438
export function normalizeWorkspaceFileReference(fileReference: string): string {
439439
const trimmed = fileReference.trim().replace(/^\/+/, '')
440+
const withoutDeletedPrefix = trimmed.startsWith('recently-deleted/')
441+
? trimmed.slice('recently-deleted/'.length)
442+
: trimmed
440443

441-
if (trimmed.startsWith('files/by-id/')) {
442-
const byIdRef = trimmed.slice('files/by-id/'.length)
444+
if (withoutDeletedPrefix.startsWith('files/by-id/')) {
445+
const byIdRef = withoutDeletedPrefix.slice('files/by-id/'.length)
443446
const match = byIdRef.match(/^([^/]+)(?:\/(?:meta\.json|content))?$/)
444447
if (match?.[1]) {
445448
return match[1]
446449
}
447450
}
448451

449-
if (trimmed.startsWith('files/')) {
450-
const withoutPrefix = trimmed.slice('files/'.length)
452+
if (withoutDeletedPrefix.startsWith('by-id/')) {
453+
const match = withoutDeletedPrefix
454+
.slice('by-id/'.length)
455+
.match(/^([^/]+)(?:\/(?:meta\.json|content))?$/)
456+
if (match?.[1]) {
457+
return match[1]
458+
}
459+
}
460+
461+
if (withoutDeletedPrefix.startsWith('files/')) {
462+
const withoutPrefix = withoutDeletedPrefix.slice('files/'.length)
451463
if (withoutPrefix.endsWith('/meta.json')) {
452464
return withoutPrefix.slice(0, -'/meta.json'.length)
453465
}
@@ -457,7 +469,7 @@ export function normalizeWorkspaceFileReference(fileReference: string): string {
457469
return withoutPrefix
458470
}
459471

460-
return trimmed
472+
return withoutDeletedPrefix
461473
}
462474

463475
/**

0 commit comments

Comments
 (0)