From 5be55d2b15de9ac7c7d63775080cdc612c6057da Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 7 Apr 2026 20:49:06 -0700 Subject: [PATCH] Patch --- .../components/agent-group/tool-call-item.tsx | 5 +- .../[workspaceId]/home/hooks/use-chat.ts | 11 +- .../tools/server/files/workspace-file.ts | 142 +++++++++++++++--- 3 files changed, 137 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index ede6fcd3ac..61ca00db3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -104,13 +104,16 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: if (toolName !== WorkspaceFile.id || !streamingArgs) return null const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/) if (!titleMatch?.[1]) return null + const opMatch = streamingArgs.match(/"operation"\s*:\s*"(\w+)"/) + const op = opMatch?.[1] ?? '' + const verb = op === 'patch' || op === 'update' ? 'Editing' : 'Writing' const unescaped = titleMatch[1] .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)) ) .replace(/\\"/g, '"') .replace(/\\\\/g, '\\') - return `Writing ${unescaped}` + return `${verb} ${unescaped}` }, [toolName, streamingArgs]) const extracted = useMemo(() => { if (toolName !== FunctionExecute.id || !streamingArgs) return null diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 08bd927d1f..7a037c97ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -942,6 +942,9 @@ export function useChat( tc.streamingArgs = (tc.streamingArgs ?? '') + delta if (tc.name === WorkspaceFile.id) { + const opMatch = tc.streamingArgs.match(/"operation"\s*:\s*"(\w+)"/) + const op = opMatch?.[1] ?? '' + const verb = op === 'patch' || op === 'update' ? 'Editing' : 'Writing' const titleMatch = tc.streamingArgs.match(/"title"\s*:\s*"([^"]*)"/) if (titleMatch?.[1]) { const unescaped = titleMatch[1] @@ -950,7 +953,7 @@ export function useChat( ) .replace(/\\"/g, '"') .replace(/\\\\/g, '\\') - tc.displayTitle = `Writing ${unescaped}` + tc.displayTitle = `${verb} ${unescaped}` } } @@ -1120,12 +1123,14 @@ export function useChat( | undefined if (name === WorkspaceFile.id) { + const operation = typeof args?.operation === 'string' ? args.operation : '' + const verb = operation === 'patch' || operation === 'update' ? 'Editing' : 'Writing' const innerArgs = args ? asPayloadRecord(args.args) : undefined const chunkTitle = innerArgs?.title as string | undefined if (chunkTitle) { - displayTitle = `Writing ${chunkTitle}` + displayTitle = `${verb} ${chunkTitle}` } else if (activeFileContextRef.current?.fileName) { - displayTitle = `Writing ${activeFileContextRef.current.fileName}` + displayTitle = `${verb} ${activeFileContextRef.current.fileName}` } } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2ab2314c41..2a2a95b7a0 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -365,16 +365,25 @@ export const workspaceFileServerTool: BaseServerTool).fileId as string | undefined - const edits = (args as Record).edits as + const edit = (args as Record).edit as + | { + mode: string + before_anchor?: string + after_anchor?: string + start_anchor?: string + end_anchor?: string + anchor?: string + content?: string + occurrence?: number + } + | undefined + const legacyEdits = (args as Record).edits as | { search: string; replace: string }[] | undefined if (!fileId) { return { success: false, message: 'fileId is required for patch operation' } } - if (!edits || !Array.isArray(edits) || edits.length === 0) { - return { success: false, message: 'edits array is required for patch operation' } - } const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { @@ -384,24 +393,122 @@ export const workspaceFileServerTool: BaseServerTool { + const trimmed = anchor.trim() + let count = 0 + for (let i = afterIndex + 1; i < lines.length; i++) { + if (lines[i].trim() === trimmed) { + count++ + if (count === occurrence) return { index: i } + } + } + if (count === 0) { + return { + index: -1, + error: `Anchor line not found in "${fileRecord.name}": "${anchor.slice(0, 100)}"`, + } + } return { - success: false, - message: `Patch failed: search string not found in file "${fileRecord.name}". Search: "${edit.search.slice(0, 100)}${edit.search.length > 100 ? '...' : ''}"`, + index: -1, + error: `Anchor line occurrence ${occurrence} not found (only ${count} match${count > 1 ? 'es' : ''}) in "${fileRecord.name}": "${anchor.slice(0, 100)}"`, } } - if (content.indexOf(edit.search, firstIdx + 1) !== -1) { + + if (edit.mode === 'replace_between') { + if (!edit.before_anchor || !edit.after_anchor) { + return { + success: false, + message: 'replace_between requires before_anchor and after_anchor', + } + } + const before = findAnchorLine(edit.before_anchor) + if (before.error) return { success: false, message: `Patch failed: ${before.error}` } + const after = findAnchorLine(edit.after_anchor, defaultOccurrence, before.index) + if (after.error) return { success: false, message: `Patch failed: ${after.error}` } + if (after.index <= before.index) { + return { + success: false, + message: 'Patch failed: after_anchor must appear after before_anchor in the file', + } + } + + const newLines = [ + ...lines.slice(0, before.index + 1), + ...(edit.content ?? '').split('\n'), + ...lines.slice(after.index), + ] + content = newLines.join('\n') + } else if (edit.mode === 'insert_after') { + if (!edit.anchor) { + return { success: false, message: 'insert_after requires anchor' } + } + const found = findAnchorLine(edit.anchor) + if (found.error) return { success: false, message: `Patch failed: ${found.error}` } + + const newLines = [ + ...lines.slice(0, found.index + 1), + ...(edit.content ?? '').split('\n'), + ...lines.slice(found.index + 1), + ] + content = newLines.join('\n') + } else if (edit.mode === 'delete_between') { + if (!edit.start_anchor || !edit.end_anchor) { + return { + success: false, + message: 'delete_between requires start_anchor and end_anchor', + } + } + const start = findAnchorLine(edit.start_anchor) + if (start.error) return { success: false, message: `Patch failed: ${start.error}` } + const end = findAnchorLine(edit.end_anchor, defaultOccurrence, start.index) + if (end.error) return { success: false, message: `Patch failed: ${end.error}` } + if (end.index <= start.index) { + return { + success: false, + message: 'Patch failed: end_anchor must appear after start_anchor in the file', + } + } + + const newLines = [...lines.slice(0, start.index), ...lines.slice(end.index)] + content = newLines.join('\n') + } else { return { success: false, - message: `Patch failed: search string is ambiguous — found at multiple locations in "${fileRecord.name}". Use a longer, unique search string.`, + message: `Unknown edit mode: "${edit.mode}". Use "replace_between", "insert_after", or "delete_between".`, } } - content = - content.slice(0, firstIdx) + - edit.replace + - content.slice(firstIdx + edit.search.length) + } else if (legacyEdits && Array.isArray(legacyEdits) && legacyEdits.length > 0) { + for (const le of legacyEdits) { + const firstIdx = content.indexOf(le.search) + if (firstIdx === -1) { + return { + success: false, + message: `Patch failed: search string not found in file "${fileRecord.name}". Search: "${le.search.slice(0, 100)}${le.search.length > 100 ? '...' : ''}"`, + } + } + if (content.indexOf(le.search, firstIdx + 1) !== -1) { + return { + success: false, + message: `Patch failed: search string is ambiguous — found at multiple locations in "${fileRecord.name}". Use a longer, unique search string.`, + } + } + content = + content.slice(0, firstIdx) + le.replace + content.slice(firstIdx + le.search.length) + } + } else { + return { + success: false, + message: 'patch requires either an edit object (with mode) or a legacy edits array', + } } const patchLowerName = fileRecord.name?.toLowerCase() ?? '' @@ -445,16 +552,17 @@ export const workspaceFileServerTool: BaseServerTool 1 ? 's' : ''} applied)`, + message: `File "${fileRecord.name}" patched successfully (${editMode} edit applied)`, data: { id: fileId, name: fileRecord.name,