Skip to content

Commit fb7b2b8

Browse files
committed
fix(files): guide retries after consumed edit intent
1 parent 9436a93 commit fb7b2b8

2 files changed

Lines changed: 107 additions & 38 deletions

File tree

apps/sim/lib/copilot/tools/server/files/edit-content.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,62 @@ describe('edit_content', () => {
124124
{ fileId: 'pdf-1' }
125125
)
126126
})
127+
128+
it('tells the agent to create a new intent when an edit fails after consuming one', async () => {
129+
consumeLatestFileIntentMock.mockResolvedValue({
130+
operation: 'patch',
131+
fileId: 'pdf-1',
132+
workspaceId: 'workspace-1',
133+
userId: 'user-1',
134+
fileRecord: { id: 'pdf-1', name: 'report.pdf' },
135+
createdAt: Date.now(),
136+
})
137+
138+
const result = await editContentServerTool.execute({ content: 'replacement' }, context)
139+
140+
expect(result).toEqual({
141+
success: false,
142+
message:
143+
'Patch intent missing edit metadata. The workspace_file intent was consumed; call workspace_file again before retrying edit_content.',
144+
})
145+
})
146+
147+
it('keeps the existing first-use guidance when no intent was consumed', async () => {
148+
consumeLatestFileIntentMock.mockResolvedValue(undefined)
149+
150+
const result = await editContentServerTool.execute({ content: 'replacement' }, context)
151+
152+
expect(result).toEqual({
153+
success: false,
154+
message:
155+
'No workspace_file context found. Call workspace_file first, wait for it to succeed, then call edit_content in the next step. Do not emit edit_content in parallel or in the same batch as workspace_file.',
156+
})
157+
})
158+
159+
it('adds the recovery guidance when document compilation returns an error', async () => {
160+
compileDocForWriteMock.mockResolvedValue({
161+
ok: false,
162+
message: 'PDF compilation failed',
163+
})
164+
165+
const result = await editContentServerTool.execute({ content: 'source' }, context)
166+
167+
expect(result).toEqual({
168+
success: false,
169+
message:
170+
'PDF compilation failed. The workspace_file intent was consumed; call workspace_file again before retrying edit_content.',
171+
})
172+
})
173+
174+
it('adds the recovery guidance when editing throws after consuming an intent', async () => {
175+
compileDocForWriteMock.mockRejectedValue(new Error('sandbox unavailable'))
176+
177+
const result = await editContentServerTool.execute({ content: 'source' }, context)
178+
179+
expect(result).toEqual({
180+
success: false,
181+
message:
182+
'Failed to edit file content. The workspace_file intent was consumed; call workspace_file again before retrying edit_content.',
183+
})
184+
})
127185
})

apps/sim/lib/copilot/tools/server/files/edit-content.ts

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ type EditContentResult = {
2929
data?: Record<string, unknown>
3030
}
3131

32+
const CONSUMED_FILE_INTENT_RETRY_GUIDANCE =
33+
'The workspace_file intent was consumed; call workspace_file again before retrying edit_content.'
34+
35+
function consumedFileIntentFailure(message: string): EditContentResult {
36+
const normalizedMessage = message.trimEnd()
37+
const separator = /[.!?]$/.test(normalizedMessage) ? ' ' : '. '
38+
return {
39+
success: false,
40+
message: `${normalizedMessage}${separator}${CONSUMED_FILE_INTENT_RETRY_GUIDANCE}`,
41+
}
42+
}
43+
3244
export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentResult> = {
3345
name: 'edit_content',
3446
async execute(params: EditContentArgs, context?: ServerToolContext): Promise<EditContentResult> {
@@ -105,17 +117,16 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
105117
case 'patch': {
106118
const existing = intent.existingContent ?? ''
107119
if (!intent.edit) {
108-
return { success: false, message: 'Patch intent missing edit metadata' }
120+
return consumedFileIntentFailure('Patch intent missing edit metadata')
109121
}
110122

111123
if (intent.edit.strategy === 'search_replace') {
112124
const search = intent.edit.search!
113125
const firstIdx = existing.indexOf(search)
114126
if (firstIdx === -1) {
115-
return {
116-
success: false,
117-
message: `Patch failed: search string not found in file "${fileRecord.name}"`,
118-
}
127+
return consumedFileIntentFailure(
128+
`Patch failed: search string not found in file "${fileRecord.name}"`
129+
)
119130
}
120131
finalContent = intent.edit.replaceAll
121132
? existing.split(search).join(content)
@@ -151,24 +162,26 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
151162

152163
if (intent.edit.mode === 'replace_between') {
153164
if (!intent.edit.before_anchor || !intent.edit.after_anchor) {
154-
return {
155-
success: false,
156-
message: 'replace_between requires before_anchor and after_anchor',
157-
}
165+
return consumedFileIntentFailure(
166+
'replace_between requires before_anchor and after_anchor'
167+
)
158168
}
159169
const before = findAnchorLine(intent.edit.before_anchor)
160-
if (before.error) return { success: false, message: `Patch failed: ${before.error}` }
170+
if (before.error) {
171+
return consumedFileIntentFailure(`Patch failed: ${before.error}`)
172+
}
161173
const after = findAnchorLine(
162174
intent.edit.after_anchor,
163175
defaultOccurrence,
164176
before.index
165177
)
166-
if (after.error) return { success: false, message: `Patch failed: ${after.error}` }
178+
if (after.error) {
179+
return consumedFileIntentFailure(`Patch failed: ${after.error}`)
180+
}
167181
if (after.index <= before.index) {
168-
return {
169-
success: false,
170-
message: 'Patch failed: after_anchor must appear after before_anchor in the file',
171-
}
182+
return consumedFileIntentFailure(
183+
'Patch failed: after_anchor must appear after before_anchor in the file'
184+
)
172185
}
173186
const newLines = [
174187
...lines.slice(0, before.index + 1),
@@ -178,10 +191,12 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
178191
finalContent = newLines.join('\n')
179192
} else if (intent.edit.mode === 'insert_after') {
180193
if (!intent.edit.anchor) {
181-
return { success: false, message: 'insert_after requires anchor' }
194+
return consumedFileIntentFailure('insert_after requires anchor')
182195
}
183196
const found = findAnchorLine(intent.edit.anchor)
184-
if (found.error) return { success: false, message: `Patch failed: ${found.error}` }
197+
if (found.error) {
198+
return consumedFileIntentFailure(`Patch failed: ${found.error}`)
199+
}
185200
const newLines = [
186201
...lines.slice(0, found.index + 1),
187202
...content.split('\n'),
@@ -190,36 +205,35 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
190205
finalContent = newLines.join('\n')
191206
} else if (intent.edit.mode === 'delete_between') {
192207
if (!intent.edit.start_anchor || !intent.edit.end_anchor) {
193-
return {
194-
success: false,
195-
message: 'delete_between requires start_anchor and end_anchor',
196-
}
208+
return consumedFileIntentFailure(
209+
'delete_between requires start_anchor and end_anchor'
210+
)
197211
}
198212
const start = findAnchorLine(intent.edit.start_anchor)
199-
if (start.error) return { success: false, message: `Patch failed: ${start.error}` }
213+
if (start.error) {
214+
return consumedFileIntentFailure(`Patch failed: ${start.error}`)
215+
}
200216
const end = findAnchorLine(intent.edit.end_anchor, defaultOccurrence, start.index)
201-
if (end.error) return { success: false, message: `Patch failed: ${end.error}` }
217+
if (end.error) {
218+
return consumedFileIntentFailure(`Patch failed: ${end.error}`)
219+
}
202220
if (end.index <= start.index) {
203-
return {
204-
success: false,
205-
message: 'Patch failed: end_anchor must appear after start_anchor in the file',
206-
}
221+
return consumedFileIntentFailure(
222+
'Patch failed: end_anchor must appear after start_anchor in the file'
223+
)
207224
}
208225
const newLines = [...lines.slice(0, start.index), ...lines.slice(end.index)]
209226
finalContent = newLines.join('\n')
210227
} else {
211-
return {
212-
success: false,
213-
message: `Unknown anchored patch mode: "${intent.edit.mode}"`,
214-
}
228+
return consumedFileIntentFailure(`Unknown anchored patch mode: "${intent.edit.mode}"`)
215229
}
216230
} else {
217-
return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` }
231+
return consumedFileIntentFailure(`Unknown patch strategy: "${intent.edit.strategy}"`)
218232
}
219233
break
220234
}
221235
default:
222-
return { success: false, message: `Unsupported operation in intent: ${operation}` }
236+
return consumedFileIntentFailure(`Unsupported operation in intent: ${operation}`)
223237
}
224238

225239
// Compile once via the right engine (or isolated-vm fallback) and resolve
@@ -235,7 +249,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
235249
fallbackMime: inferContentType(fileRecord.name, intent.contentType),
236250
})
237251
if (!compiled.ok) {
238-
return { success: false, message: compiled.message }
252+
return consumedFileIntentFailure(compiled.message)
239253
}
240254

241255
const fileBuffer = Buffer.from(finalContent, 'utf-8')
@@ -290,10 +304,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
290304
error: errorMessage,
291305
userId: context.userId,
292306
})
293-
return {
294-
success: false,
295-
message: safeMessage,
296-
}
307+
return consumedFileIntentFailure(safeMessage)
297308
}
298309
},
299310
}

0 commit comments

Comments
 (0)