Skip to content

Commit b03c1dc

Browse files
committed
fix(copilot): reject unresolved image references
Require every declared image reference to resolve and load before calling Gemini, while preserving text-only generation when inputs are omitted.
1 parent f41e27a commit b03c1dc

4 files changed

Lines changed: 325 additions & 52 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2641,8 +2641,10 @@ export const GenerateImage: ToolCatalogEntry = {
26412641
},
26422642
required: ['path'],
26432643
},
2644+
minItems: 1,
26442645
},
26452646
},
2647+
required: ['files'],
26462648
},
26472649
outputs: {
26482650
type: 'object',

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,8 +2577,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
25772577
},
25782578
required: ['path'],
25792579
},
2580+
minItems: 1,
25802581
},
25812582
},
2583+
required: ['files'],
25822584
},
25832585
outputs: {
25842586
type: 'object',
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const {
7+
assertOpaqueWorkspaceFileModelSafeMock,
8+
createWorkspaceFileSecretProvenanceFromRegistryMock,
9+
executeCopilotFileUseCaseMock,
10+
generateContentMock,
11+
resolveCopilotWorkspaceFileReferenceMock,
12+
writeCopilotWorkspaceFileByPathMock,
13+
} = vi.hoisted(() => ({
14+
assertOpaqueWorkspaceFileModelSafeMock: vi.fn(),
15+
createWorkspaceFileSecretProvenanceFromRegistryMock: vi.fn(),
16+
executeCopilotFileUseCaseMock: vi.fn(),
17+
generateContentMock: vi.fn(),
18+
resolveCopilotWorkspaceFileReferenceMock: vi.fn(),
19+
writeCopilotWorkspaceFileByPathMock: vi.fn(),
20+
}))
21+
22+
vi.mock('@google/genai', () => ({
23+
GoogleGenAI: class GoogleGenAI {
24+
models = { generateContent: generateContentMock }
25+
},
26+
}))
27+
28+
vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({
29+
executeCopilotFileUseCase: (...args: unknown[]) => executeCopilotFileUseCaseMock(...args),
30+
resolveCopilotWorkspaceFileReference: (...args: unknown[]) =>
31+
resolveCopilotWorkspaceFileReferenceMock(...args),
32+
}))
33+
34+
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
35+
GenerateImage: { id: 'generate_image' },
36+
}))
37+
38+
vi.mock('@/lib/copilot/tools/server/model-input', () => ({
39+
assertOpaqueWorkspaceFileModelSafe: (...args: unknown[]) =>
40+
assertOpaqueWorkspaceFileModelSafeMock(...args),
41+
}))
42+
43+
vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
44+
writeCopilotWorkspaceFileByPath: (...args: unknown[]) =>
45+
writeCopilotWorkspaceFileByPathMock(...args),
46+
}))
47+
48+
vi.mock('@/lib/core/config/api-keys', () => ({
49+
getRotatingApiKey: vi.fn(() => 'api-key'),
50+
}))
51+
52+
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({
53+
createWorkspaceFileSecretProvenanceFromRegistry: (...args: unknown[]) =>
54+
createWorkspaceFileSecretProvenanceFromRegistryMock(...args),
55+
}))
56+
57+
vi.mock('@/lib/workspace-files/application/operations', () => ({
58+
fileOperations: { readContent: { id: 'file.readContent' } },
59+
}))
60+
61+
vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({
62+
readWorkspaceFileContent: { execute: vi.fn() },
63+
}))
64+
65+
import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
66+
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
67+
68+
const context: ServerToolContext = {
69+
userId: 'user-1',
70+
workspaceId: 'workspace-1',
71+
toolCallId: 'tool-1',
72+
copilotToolExecution: true,
73+
}
74+
75+
const referenceFile = {
76+
id: 'file-1',
77+
workspaceId: 'workspace-1',
78+
name: 'reference.png',
79+
key: 'workspace/workspace-1/reference.png',
80+
path: '/api/files/serve/reference.png',
81+
size: 9,
82+
type: 'image/png',
83+
uploadedBy: 'user-1',
84+
uploadedAt: new Date('2026-08-31T00:00:00.000Z'),
85+
updatedAt: new Date('2026-08-31T00:00:00.000Z'),
86+
storageContext: 'workspace' as const,
87+
}
88+
89+
describe('generateImageServerTool reference inputs', () => {
90+
beforeEach(() => {
91+
vi.clearAllMocks()
92+
resolveCopilotWorkspaceFileReferenceMock.mockResolvedValue(referenceFile)
93+
assertOpaqueWorkspaceFileModelSafeMock.mockResolvedValue(undefined)
94+
executeCopilotFileUseCaseMock.mockResolvedValue({
95+
file: referenceFile,
96+
content: Buffer.from('reference'),
97+
})
98+
generateContentMock.mockResolvedValue({
99+
candidates: [
100+
{
101+
content: {
102+
parts: [{ inlineData: { data: 'Z2VuZXJhdGVk', mimeType: 'image/png' } }],
103+
},
104+
},
105+
],
106+
})
107+
createWorkspaceFileSecretProvenanceFromRegistryMock.mockResolvedValue({
108+
safe: true,
109+
provenance: { status: 'exact', entries: [] },
110+
})
111+
writeCopilotWorkspaceFileByPathMock.mockResolvedValue({
112+
id: 'output-1',
113+
name: 'generated-image.png',
114+
size: 9,
115+
contentType: 'image/png',
116+
vfsPath: 'files/generated-image.png',
117+
downloadUrl: '/api/files/serve/generated-image.png',
118+
mode: 'create',
119+
})
120+
})
121+
122+
it('keeps inputs optional for text-to-image generation', async () => {
123+
const result = await generateImageServerTool.execute({ prompt: 'Draw a lighthouse' }, context)
124+
125+
expect(result.success).toBe(true)
126+
expect(resolveCopilotWorkspaceFileReferenceMock).not.toHaveBeenCalled()
127+
expect(generateContentMock).toHaveBeenCalledWith(
128+
expect.objectContaining({
129+
contents: [
130+
expect.objectContaining({
131+
parts: [
132+
expect.objectContaining({ text: expect.stringContaining('Draw a lighthouse') }),
133+
],
134+
}),
135+
],
136+
})
137+
)
138+
})
139+
140+
it.each([{ inputs: {} }, { inputs: { files: [] } }])(
141+
'rejects supplied inputs without files before calling the provider',
142+
async ({ inputs }) => {
143+
const result = await generateImageServerTool.execute(
144+
{ prompt: 'Edit this image', inputs },
145+
context
146+
)
147+
148+
expect(result).toEqual(
149+
expect.objectContaining({
150+
success: false,
151+
message: expect.stringContaining('inputs.files'),
152+
})
153+
)
154+
expect(generateContentMock).not.toHaveBeenCalled()
155+
}
156+
)
157+
158+
it('loads a valid reference into inlineData before calling the provider', async () => {
159+
const result = await generateImageServerTool.execute(
160+
{
161+
prompt: 'Turn the sky purple',
162+
inputs: { files: [{ path: 'files/reference.png' }] },
163+
},
164+
context
165+
)
166+
167+
expect(result.success).toBe(true)
168+
expect(generateContentMock).toHaveBeenCalledWith(
169+
expect.objectContaining({
170+
contents: [
171+
expect.objectContaining({
172+
parts: [
173+
{ inlineData: { mimeType: 'image/png', data: 'cmVmZXJlbmNl' } },
174+
expect.objectContaining({ text: expect.stringContaining('Turn the sky purple') }),
175+
],
176+
}),
177+
],
178+
})
179+
)
180+
expect(result.message).toContain('edited')
181+
})
182+
183+
it('fails when a reference cannot be resolved before calling the provider', async () => {
184+
resolveCopilotWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found'))
185+
186+
const result = await generateImageServerTool.execute(
187+
{
188+
prompt: 'Edit this image',
189+
inputs: { files: [{ path: 'files/missing.png' }] },
190+
},
191+
context
192+
)
193+
194+
expect(result).toEqual(
195+
expect.objectContaining({
196+
success: false,
197+
message: expect.stringContaining('File not found'),
198+
})
199+
)
200+
expect(generateContentMock).not.toHaveBeenCalled()
201+
})
202+
203+
it('does not generate from a partial list when any reference is missing', async () => {
204+
resolveCopilotWorkspaceFileReferenceMock
205+
.mockResolvedValueOnce(referenceFile)
206+
.mockRejectedValueOnce(new Error('Second file not found'))
207+
208+
const result = await generateImageServerTool.execute(
209+
{
210+
prompt: 'Combine these images',
211+
inputs: {
212+
files: [{ path: 'files/reference.png' }, { path: 'files/missing.png' }],
213+
},
214+
},
215+
context
216+
)
217+
218+
expect(result).toEqual(
219+
expect.objectContaining({
220+
success: false,
221+
message: expect.stringContaining('Second file not found'),
222+
})
223+
)
224+
expect(generateContentMock).not.toHaveBeenCalled()
225+
})
226+
227+
it('fails when reference bytes cannot be read before calling the provider', async () => {
228+
executeCopilotFileUseCaseMock.mockRejectedValue(new Error('Unable to read file'))
229+
230+
const result = await generateImageServerTool.execute(
231+
{
232+
prompt: 'Edit this image',
233+
inputs: { files: [{ path: 'files/reference.png' }] },
234+
},
235+
context
236+
)
237+
238+
expect(result).toEqual(
239+
expect.objectContaining({
240+
success: false,
241+
message: expect.stringContaining('Unable to read file'),
242+
})
243+
)
244+
expect(generateContentMock).not.toHaveBeenCalled()
245+
})
246+
247+
it('explains how to save an uploaded image before using it as a reference', async () => {
248+
const result = await generateImageServerTool.execute(
249+
{
250+
prompt: 'Edit this image',
251+
inputs: { files: [{ path: 'uploads/reference.png' }] },
252+
},
253+
context
254+
)
255+
256+
expect(result).toEqual(
257+
expect.objectContaining({
258+
success: false,
259+
message: expect.stringMatching(/save_upload[\s\S]*files\//),
260+
})
261+
)
262+
expect(resolveCopilotWorkspaceFileReferenceMock).not.toHaveBeenCalled()
263+
expect(generateContentMock).not.toHaveBeenCalled()
264+
})
265+
})

0 commit comments

Comments
 (0)