Skip to content

Commit d50aefc

Browse files
fix(custom-tools): updates to legacy + copilot generated custom tools (#1960)
* fix(custom-tools): updates to existing tools * don't reorder custom tools in modal based on edit time * restructure custom tools to persist copilot generated tools * fix tests
1 parent 6513cbb commit d50aefc

File tree

5 files changed

+228
-161
lines changed

5 files changed

+228
-161
lines changed

apps/sim/app/api/tools/custom/route.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe('Custom Tools API Routes', () => {
7070
const mockSelect = vi.fn()
7171
const mockFrom = vi.fn()
7272
const mockWhere = vi.fn()
73+
const mockOrderBy = vi.fn()
7374
const mockInsert = vi.fn()
7475
const mockValues = vi.fn()
7576
const mockUpdate = vi.fn()
@@ -84,10 +85,23 @@ describe('Custom Tools API Routes', () => {
8485
// Reset all mock implementations
8586
mockSelect.mockReturnValue({ from: mockFrom })
8687
mockFrom.mockReturnValue({ where: mockWhere })
87-
// where() can be called with limit() or directly awaited
88-
// Create a mock query builder that supports both patterns
88+
// where() can be called with orderBy(), limit(), or directly awaited
89+
// Create a mock query builder that supports all patterns
8990
mockWhere.mockImplementation((condition) => {
90-
// Return an object that is both awaitable and has a limit() method
91+
// Return an object that is both awaitable and has orderBy() and limit() methods
92+
const queryBuilder = {
93+
orderBy: mockOrderBy,
94+
limit: mockLimit,
95+
then: (resolve: (value: typeof sampleTools) => void) => {
96+
resolve(sampleTools)
97+
return queryBuilder
98+
},
99+
catch: (reject: (error: Error) => void) => queryBuilder,
100+
}
101+
return queryBuilder
102+
})
103+
mockOrderBy.mockImplementation(() => {
104+
// orderBy returns an awaitable query builder
91105
const queryBuilder = {
92106
limit: mockLimit,
93107
then: (resolve: (value: typeof sampleTools) => void) => {
@@ -120,9 +134,22 @@ describe('Custom Tools API Routes', () => {
120134
const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet })
121135
const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere })
122136

123-
// Transaction where() should also support the query builder pattern
137+
// Transaction where() should also support the query builder pattern with orderBy
138+
const txMockOrderBy = vi.fn().mockImplementation(() => {
139+
const queryBuilder = {
140+
limit: mockLimit,
141+
then: (resolve: (value: typeof sampleTools) => void) => {
142+
resolve(sampleTools)
143+
return queryBuilder
144+
},
145+
catch: (reject: (error: Error) => void) => queryBuilder,
146+
}
147+
return queryBuilder
148+
})
149+
124150
const txMockWhere = vi.fn().mockImplementation((condition) => {
125151
const queryBuilder = {
152+
orderBy: txMockOrderBy,
126153
limit: mockLimit,
127154
then: (resolve: (value: typeof sampleTools) => void) => {
128155
resolve(sampleTools)
@@ -201,13 +228,19 @@ describe('Custom Tools API Routes', () => {
201228
or: vi.fn().mockImplementation((...conditions) => ({ operator: 'or', conditions })),
202229
isNull: vi.fn().mockImplementation((field) => ({ field, operator: 'isNull' })),
203230
ne: vi.fn().mockImplementation((field, value) => ({ field, value, operator: 'ne' })),
231+
desc: vi.fn().mockImplementation((field) => ({ field, operator: 'desc' })),
204232
}
205233
})
206234

207235
// Mock utils
208236
vi.doMock('@/lib/utils', () => ({
209237
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
210238
}))
239+
240+
// Mock custom tools operations
241+
vi.doMock('@/lib/custom-tools/operations', () => ({
242+
upsertCustomTools: vi.fn().mockResolvedValue(sampleTools),
243+
}))
211244
})
212245

213246
afterEach(() => {
@@ -224,8 +257,10 @@ describe('Custom Tools API Routes', () => {
224257
'http://localhost:3000/api/tools/custom?workspaceId=workspace-123'
225258
)
226259

227-
// Simulate DB returning tools
228-
mockWhere.mockReturnValueOnce(Promise.resolve(sampleTools))
260+
// Simulate DB returning tools with orderBy chain
261+
mockWhere.mockReturnValueOnce({
262+
orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)),
263+
})
229264

230265
// Import handler after mocks are set up
231266
const { GET } = await import('@/app/api/tools/custom/route')
@@ -243,6 +278,7 @@ describe('Custom Tools API Routes', () => {
243278
expect(mockSelect).toHaveBeenCalled()
244279
expect(mockFrom).toHaveBeenCalled()
245280
expect(mockWhere).toHaveBeenCalled()
281+
expect(mockOrderBy).toHaveBeenCalled()
246282
})
247283

248284
it('should handle unauthorized access', async () => {

apps/sim/app/api/tools/custom/route.ts

Lines changed: 11 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { db } from '@sim/db'
22
import { customTools, workflow } from '@sim/db/schema'
3-
import { and, eq, isNull, ne, or } from 'drizzle-orm'
3+
import { and, desc, eq, isNull, or } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { z } from 'zod'
66
import { checkHybridAuth } from '@/lib/auth/hybrid'
7+
import { upsertCustomTools } from '@/lib/custom-tools/operations'
78
import { createLogger } from '@/lib/logs/console/logger'
89
import { getUserEntityPermissions } from '@/lib/permissions/utils'
910
import { generateRequestId } from '@/lib/utils'
@@ -101,6 +102,7 @@ export async function GET(request: NextRequest) {
101102
.select()
102103
.from(customTools)
103104
.where(or(...conditions))
105+
.orderBy(desc(customTools.createdAt))
104106

105107
return NextResponse.json({ data: result }, { status: 200 })
106108
} catch (error) {
@@ -150,96 +152,15 @@ export async function POST(req: NextRequest) {
150152
return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
151153
}
152154

153-
// Use a transaction for multi-step database operations
154-
return await db.transaction(async (tx) => {
155-
// Process each tool: either update existing or create new
156-
for (const tool of tools) {
157-
const nowTime = new Date()
158-
159-
if (tool.id) {
160-
// Check if tool exists and belongs to the workspace
161-
const existingTool = await tx
162-
.select()
163-
.from(customTools)
164-
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
165-
.limit(1)
166-
167-
if (existingTool.length > 0) {
168-
// Tool exists - check if name changed and if new name conflicts
169-
if (existingTool[0].title !== tool.title) {
170-
// Check for duplicate name in workspace (excluding current tool)
171-
const duplicateTool = await tx
172-
.select()
173-
.from(customTools)
174-
.where(
175-
and(
176-
eq(customTools.workspaceId, workspaceId),
177-
eq(customTools.title, tool.title),
178-
ne(customTools.id, tool.id)
179-
)
180-
)
181-
.limit(1)
182-
183-
if (duplicateTool.length > 0) {
184-
return NextResponse.json(
185-
{
186-
error: `A tool with the name "${tool.title}" already exists in this workspace`,
187-
},
188-
{ status: 400 }
189-
)
190-
}
191-
}
192-
193-
// Update existing tool
194-
await tx
195-
.update(customTools)
196-
.set({
197-
title: tool.title,
198-
schema: tool.schema,
199-
code: tool.code,
200-
updatedAt: nowTime,
201-
})
202-
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
203-
continue
204-
}
205-
}
206-
207-
// Creating new tool - check for duplicate names in workspace
208-
const duplicateTool = await tx
209-
.select()
210-
.from(customTools)
211-
.where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title)))
212-
.limit(1)
213-
214-
if (duplicateTool.length > 0) {
215-
return NextResponse.json(
216-
{ error: `A tool with the name "${tool.title}" already exists in this workspace` },
217-
{ status: 400 }
218-
)
219-
}
220-
221-
// Create new tool
222-
const newToolId = tool.id || crypto.randomUUID()
223-
await tx.insert(customTools).values({
224-
id: newToolId,
225-
workspaceId,
226-
userId,
227-
title: tool.title,
228-
schema: tool.schema,
229-
code: tool.code,
230-
createdAt: nowTime,
231-
updatedAt: nowTime,
232-
})
233-
}
234-
235-
// Fetch and return the created/updated tools
236-
const resultTools = await tx
237-
.select()
238-
.from(customTools)
239-
.where(eq(customTools.workspaceId, workspaceId))
240-
241-
return NextResponse.json({ success: true, data: resultTools })
155+
// Use the extracted upsert function
156+
const resultTools = await upsertCustomTools({
157+
tools,
158+
workspaceId,
159+
userId,
160+
requestId,
242161
})
162+
163+
return NextResponse.json({ success: true, data: resultTools })
243164
} catch (validationError) {
244165
if (validationError instanceof z.ZodError) {
245166
logger.warn(`[${requestId}] Invalid custom tools data`, {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ export function ToolInput({
477477
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
478478
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
479479
const customTools = useCustomToolsStore((state) => state.getAllTools())
480+
const fetchCustomTools = useCustomToolsStore((state) => state.fetchTools)
480481
const subBlockStore = useSubBlockStore()
481482

482483
// MCP tools integration
@@ -487,6 +488,13 @@ export function ToolInput({
487488
refreshTools,
488489
} = useMcpTools(workspaceId)
489490

491+
// Fetch custom tools on mount
492+
useEffect(() => {
493+
if (workspaceId) {
494+
fetchCustomTools(workspaceId)
495+
}
496+
}, [workspaceId, fetchCustomTools])
497+
490498
// Get the current model from the 'model' subblock
491499
const modelValue = useSubBlockStore.getState().getValue(blockId, 'model')
492500
const model = typeof modelValue === 'string' ? modelValue : ''
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { db } from '@sim/db'
2+
import { customTools } from '@sim/db/schema'
3+
import { and, desc, eq, isNull } from 'drizzle-orm'
4+
import { createLogger } from '@/lib/logs/console/logger'
5+
import { generateRequestId } from '@/lib/utils'
6+
7+
const logger = createLogger('CustomToolsOperations')
8+
9+
/**
10+
* Internal function to create/update custom tools
11+
* Can be called from API routes or internal services
12+
*/
13+
export async function upsertCustomTools(params: {
14+
tools: Array<{
15+
id?: string
16+
title: string
17+
schema: any
18+
code: string
19+
}>
20+
workspaceId: string
21+
userId: string
22+
requestId?: string
23+
}) {
24+
const { tools, workspaceId, userId, requestId = generateRequestId() } = params
25+
26+
// Use a transaction for multi-step database operations
27+
return await db.transaction(async (tx) => {
28+
// Process each tool: either update existing or create new
29+
for (const tool of tools) {
30+
const nowTime = new Date()
31+
32+
if (tool.id) {
33+
// First, check if tool exists in the workspace
34+
const existingWorkspaceTool = await tx
35+
.select()
36+
.from(customTools)
37+
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
38+
.limit(1)
39+
40+
if (existingWorkspaceTool.length > 0) {
41+
// Tool exists in workspace
42+
const newFunctionName = tool.schema?.function?.name
43+
if (!newFunctionName) {
44+
throw new Error('Tool schema must include a function name')
45+
}
46+
47+
// Check if function name has changed
48+
if (tool.id !== newFunctionName) {
49+
throw new Error(
50+
`Cannot change function name from "${tool.id}" to "${newFunctionName}". Please create a new tool instead.`
51+
)
52+
}
53+
54+
// Update existing workspace tool
55+
await tx
56+
.update(customTools)
57+
.set({
58+
title: tool.title,
59+
schema: tool.schema,
60+
code: tool.code,
61+
updatedAt: nowTime,
62+
})
63+
.where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId)))
64+
continue
65+
}
66+
67+
// Check if this is a legacy tool (no workspaceId, belongs to user)
68+
const existingLegacyTool = await tx
69+
.select()
70+
.from(customTools)
71+
.where(
72+
and(
73+
eq(customTools.id, tool.id),
74+
isNull(customTools.workspaceId),
75+
eq(customTools.userId, userId)
76+
)
77+
)
78+
.limit(1)
79+
80+
if (existingLegacyTool.length > 0) {
81+
// Legacy tool found - update it without migrating to workspace
82+
await tx
83+
.update(customTools)
84+
.set({
85+
title: tool.title,
86+
schema: tool.schema,
87+
code: tool.code,
88+
updatedAt: nowTime,
89+
})
90+
.where(eq(customTools.id, tool.id))
91+
92+
logger.info(`[${requestId}] Updated legacy tool ${tool.id}`)
93+
continue
94+
}
95+
}
96+
97+
// Creating new tool - use function name as ID for consistency
98+
const functionName = tool.schema?.function?.name
99+
if (!functionName) {
100+
throw new Error('Tool schema must include a function name')
101+
}
102+
103+
// Check for duplicate function names in workspace
104+
const duplicateFunction = await tx
105+
.select()
106+
.from(customTools)
107+
.where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.id, functionName)))
108+
.limit(1)
109+
110+
if (duplicateFunction.length > 0) {
111+
throw new Error(
112+
`A tool with the function name "${functionName}" already exists in this workspace`
113+
)
114+
}
115+
116+
// Create new tool using function name as ID
117+
await tx.insert(customTools).values({
118+
id: functionName,
119+
workspaceId,
120+
userId,
121+
title: tool.title,
122+
schema: tool.schema,
123+
code: tool.code,
124+
createdAt: nowTime,
125+
updatedAt: nowTime,
126+
})
127+
}
128+
129+
// Fetch and return the created/updated tools
130+
const resultTools = await tx
131+
.select()
132+
.from(customTools)
133+
.where(eq(customTools.workspaceId, workspaceId))
134+
.orderBy(desc(customTools.createdAt))
135+
136+
return resultTools
137+
})
138+
}

0 commit comments

Comments
 (0)