Skip to content

Commit 3d13e88

Browse files
committed
fix(copilot): bind workflow tool completions
1 parent 94d38e8 commit 3d13e88

18 files changed

Lines changed: 1298 additions & 121 deletions

File tree

apps/sim/app/api/copilot/confirm/route.test.ts

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/app/api/copilot/confirm/route.ts

Lines changed: 160 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ import {
1010
ASYNC_TOOL_STATUS,
1111
type AsyncCompletionData,
1212
type AsyncConfirmationStatus,
13+
isDeliveredAsyncStatus,
14+
isTerminalAsyncStatus,
15+
isWorkflowToolExecutionClaimable,
1316
} from '@/lib/copilot/async-runs/lifecycle'
1417
import {
1518
completeAsyncToolCall,
1619
detachAsyncToolCall,
1720
getAsyncToolCall,
21+
getClaimedWorkflowExecutionId,
1822
getRunSegment,
1923
} from '@/lib/copilot/async-runs/repository'
2024
import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
@@ -35,11 +39,14 @@ import {
3539
} from '@/lib/copilot/request/tools/client-completion-seal.server'
3640
import {
3741
createStructuralWorkflowToolCompletionData,
42+
getWorkflowToolCompletionExecutionId,
3843
getWorkflowToolCompletionMessage,
44+
getWorkflowToolConfirmationStatus,
3945
isWorkflowToolName,
4046
resolveWorkflowToolTargetId,
4147
} from '@/lib/copilot/tools/workflow-tools'
4248
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
49+
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
4350

4451
const logger = createLogger('CopilotConfirmAPI')
4552

@@ -50,6 +57,14 @@ function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string
5057
return 'Tool failed'
5158
}
5259

60+
function createConfirmationResponse(
61+
toolCallId: string,
62+
status: AsyncConfirmationStatus,
63+
message: string
64+
): NextResponse {
65+
return NextResponse.json({ success: true, message, toolCallId, status })
66+
}
67+
5368
/** Atomically finalize or detach a client tool before publishing its wakeup event. */
5469
async function updateToolCallStatus(
5570
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
@@ -61,7 +76,9 @@ async function updateToolCallStatus(
6176
const toolCallId = existing.toolCallId
6277
try {
6378
if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
64-
const detached = await detachAsyncToolCall(toolCallId)
79+
const detached = executionId
80+
? await detachAsyncToolCall(toolCallId, { preserveClaim: true })
81+
: await detachAsyncToolCall(toolCallId)
6582
if (!detached) return false
6683
publishToolConfirmation({
6784
toolCallId,
@@ -143,7 +160,13 @@ export const POST = withRouteHandler((req: NextRequest) => {
143160
}
144161
)
145162
if (!parsed.success) return parsed.response
146-
const { toolCallId, executionId, status, message, data } = parsed.data.body
163+
const {
164+
toolCallId,
165+
executionId: submittedExecutionId,
166+
status,
167+
message,
168+
data,
169+
} = parsed.data.body
147170
span.setAttributes({
148171
[TraceAttr.ToolCallId]: toolCallId,
149172
[TraceAttr.ToolConfirmationStatus]: status,
@@ -181,22 +204,142 @@ export const POST = withRouteHandler((req: NextRequest) => {
181204
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
182205
}
183206

207+
const isWorkflowTool = isWorkflowToolName(existing.toolName || '')
208+
const workflowId = isWorkflowTool
209+
? resolveWorkflowToolTargetId(existing.args, run.workflowId)
210+
: undefined
211+
212+
if (isWorkflowTool && isTerminalAsyncStatus(existing.status)) {
213+
const executionId = getWorkflowToolCompletionExecutionId(existing.result)
214+
if (
215+
executionId &&
216+
submittedExecutionId !== undefined &&
217+
submittedExecutionId !== executionId
218+
) {
219+
span.setAttribute(
220+
TraceAttr.CopilotConfirmOutcome,
221+
CopilotConfirmOutcome.ToolCallNotFound
222+
)
223+
return createNotFoundResponse('Completed workflow execution not found')
224+
}
225+
226+
const terminalStatus = getWorkflowToolConfirmationStatus(existing.status)
227+
span.setAttributes({
228+
[TraceAttr.ToolConfirmationStatus]: terminalStatus,
229+
[TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered,
230+
})
231+
return createConfirmationResponse(
232+
toolCallId,
233+
terminalStatus,
234+
getWorkflowToolCompletionMessage(terminalStatus)
235+
)
236+
}
237+
238+
if (isWorkflowTool && isDeliveredAsyncStatus(existing.status)) {
239+
const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy)
240+
if (
241+
claimedExecutionId &&
242+
submittedExecutionId !== undefined &&
243+
submittedExecutionId !== claimedExecutionId
244+
) {
245+
span.setAttribute(
246+
TraceAttr.CopilotConfirmOutcome,
247+
CopilotConfirmOutcome.ToolCallNotFound
248+
)
249+
return createNotFoundResponse('Bound workflow tool call not found')
250+
}
251+
252+
span.setAttributes({
253+
[TraceAttr.ToolConfirmationStatus]: ASYNC_TOOL_CONFIRMATION_STATUS.background,
254+
[TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered,
255+
})
256+
return createConfirmationResponse(
257+
toolCallId,
258+
ASYNC_TOOL_CONFIRMATION_STATUS.background,
259+
getWorkflowToolCompletionMessage(ASYNC_TOOL_CONFIRMATION_STATUS.background)
260+
)
261+
}
262+
263+
const isUnboundTerminalWorkflowOutcome =
264+
status === ASYNC_TOOL_CONFIRMATION_STATUS.error ||
265+
status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled
266+
const isMutableClientToolCall = isWorkflowTool
267+
? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision)
268+
: existing.status === ASYNC_TOOL_STATUS.running
184269
if (
185-
(isBrowserToolName(existing.toolName) || isTerminalToolName(existing.toolName)) &&
186-
existing.status !== ASYNC_TOOL_STATUS.running
270+
(isBrowserToolName(existing.toolName) ||
271+
isTerminalToolName(existing.toolName) ||
272+
isWorkflowTool) &&
273+
!isMutableClientToolCall
187274
) {
188275
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
189276
return createNotFoundResponse('Running client tool call not found')
190277
}
191278

192-
const isWorkflowTool = isWorkflowToolName(existing.toolName || '')
193-
const workflowId = isWorkflowTool
194-
? resolveWorkflowToolTargetId(existing.args, run.workflowId)
195-
: undefined
279+
let effectiveStatus = status
280+
let executionId = submittedExecutionId
281+
282+
if (isWorkflowTool) {
283+
const claimedExecutionId = getClaimedWorkflowExecutionId(existing.claimedBy)
284+
const hasForeignClaim =
285+
existing.claimedBy !== null && existing.claimedBy !== undefined && !claimedExecutionId
286+
287+
if (
288+
hasForeignClaim ||
289+
(claimedExecutionId &&
290+
submittedExecutionId !== undefined &&
291+
submittedExecutionId !== claimedExecutionId)
292+
) {
293+
span.setAttribute(
294+
TraceAttr.CopilotConfirmOutcome,
295+
CopilotConfirmOutcome.ToolCallNotFound
296+
)
297+
return createNotFoundResponse('Bound workflow tool call not found')
298+
}
299+
300+
const candidateExecutionId = claimedExecutionId ?? submittedExecutionId
301+
const trustedExecution =
302+
status !== ASYNC_TOOL_CONFIRMATION_STATUS.background &&
303+
candidateExecutionId &&
304+
workflowId
305+
? await getTrustedWorkflowToolExecution(candidateExecutionId, workflowId, toolCallId)
306+
: null
307+
308+
if (claimedExecutionId) {
309+
executionId = claimedExecutionId
310+
if (status !== ASYNC_TOOL_CONFIRMATION_STATUS.background) {
311+
if (trustedExecution) {
312+
effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status)
313+
} else if (!isUnboundTerminalWorkflowOutcome) {
314+
span.setAttribute(
315+
TraceAttr.CopilotConfirmOutcome,
316+
CopilotConfirmOutcome.ToolCallNotFound
317+
)
318+
return createNotFoundResponse('Completed workflow execution not found')
319+
}
320+
}
321+
} else if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
322+
executionId = submittedExecutionId
323+
} else if (trustedExecution) {
324+
executionId = trustedExecution.executionId
325+
effectiveStatus = getWorkflowToolConfirmationStatus(trustedExecution.status)
326+
} else if (!isUnboundTerminalWorkflowOutcome) {
327+
effectiveStatus = ASYNC_TOOL_CONFIRMATION_STATUS.error
328+
executionId = undefined
329+
} else {
330+
executionId = undefined
331+
}
332+
}
333+
334+
span.setAttribute(TraceAttr.ToolConfirmationStatus, effectiveStatus)
196335
const projected = isWorkflowTool
197336
? {
198-
message: getWorkflowToolCompletionMessage(status),
199-
data: createStructuralWorkflowToolCompletionData(status, workflowId, executionId),
337+
message: getWorkflowToolCompletionMessage(effectiveStatus),
338+
data: createStructuralWorkflowToolCompletionData(
339+
effectiveStatus,
340+
workflowId,
341+
executionId
342+
),
200343
}
201344
: {
202345
message: getClientToolCompletionMessage(status),
@@ -214,7 +357,7 @@ export const POST = withRouteHandler((req: NextRequest) => {
214357

215358
const updated = await updateToolCallStatus(
216359
existing,
217-
status,
360+
effectiveStatus,
218361
projected.message,
219362
projected.data,
220363
isWorkflowTool ? executionId : undefined
@@ -224,8 +367,8 @@ export const POST = withRouteHandler((req: NextRequest) => {
224367
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
225368
userId: authenticatedUserId,
226369
toolCallId,
227-
status,
228-
internalStatus: status,
370+
status: effectiveStatus,
371+
internalStatus: effectiveStatus,
229372
message: projected.message,
230373
})
231374
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed)
@@ -234,12 +377,11 @@ export const POST = withRouteHandler((req: NextRequest) => {
234377
}
235378

236379
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered)
237-
return NextResponse.json({
238-
success: true,
239-
message: projected.message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
380+
return createConfirmationResponse(
240381
toolCallId,
241-
status,
242-
})
382+
effectiveStatus,
383+
projected.message || `Tool call ${toolCallId} has been ${effectiveStatus.toLowerCase()}`
384+
)
243385
} catch (error) {
244386
const duration = tracker.getDuration()
245387

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
6+
import { NextRequest } from 'next/server'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const {
10+
getAsyncToolCall,
11+
getRunSegment,
12+
recordToolPermissionDecision,
13+
publishToolPermissionDecision,
14+
addAutoAllowedTool,
15+
addChatAutoAllowedTool,
16+
} = vi.hoisted(() => ({
17+
getAsyncToolCall: vi.fn(),
18+
getRunSegment: vi.fn(),
19+
recordToolPermissionDecision: vi.fn(),
20+
publishToolPermissionDecision: vi.fn(),
21+
addAutoAllowedTool: vi.fn(),
22+
addChatAutoAllowedTool: vi.fn(),
23+
}))
24+
25+
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
26+
27+
vi.mock('@/lib/copilot/async-runs/repository', () => ({
28+
getAsyncToolCall,
29+
getRunSegment,
30+
recordToolPermissionDecision,
31+
}))
32+
33+
vi.mock('@/lib/copilot/persistence/tool-permission', () => ({
34+
publishToolPermissionDecision,
35+
TOOL_PERMISSION_DECISION: {
36+
allow: 'allow',
37+
allow_chat: 'allow_chat',
38+
always_allow: 'always_allow',
39+
skip: 'skip',
40+
},
41+
}))
42+
43+
vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({
44+
addAutoAllowedTool,
45+
addChatAutoAllowedTool,
46+
}))
47+
48+
vi.mock('@/lib/core/config/env-flags', () => ({
49+
isCopilotToolPermissionsEnabled: true,
50+
}))
51+
52+
import { POST } from './route'
53+
54+
describe('Copilot tool permission API', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
58+
userId: 'user-1',
59+
isAuthenticated: true,
60+
})
61+
getAsyncToolCall.mockResolvedValue({
62+
toolCallId: 'tool-1',
63+
runId: 'run-1',
64+
toolName: 'run_workflow',
65+
status: 'pending',
66+
permissionDecision: null,
67+
})
68+
getRunSegment.mockResolvedValue({
69+
id: 'run-1',
70+
userId: 'user-1',
71+
chatId: 'chat-1',
72+
})
73+
recordToolPermissionDecision.mockResolvedValue({
74+
toolCallId: 'tool-1',
75+
runId: 'run-1',
76+
toolName: 'run_workflow',
77+
status: 'pending',
78+
permissionDecision: 'allow',
79+
permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'),
80+
})
81+
addAutoAllowedTool.mockResolvedValue(undefined)
82+
addChatAutoAllowedTool.mockResolvedValue(undefined)
83+
})
84+
85+
function createRequest(decision: 'allow' | 'allow_chat' | 'always_allow' | 'skip') {
86+
return new NextRequest('http://localhost:3000/api/copilot/tool-permission', {
87+
method: 'POST',
88+
headers: { 'Content-Type': 'application/json' },
89+
body: JSON.stringify({ decisions: [{ toolCallId: 'tool-1', decision }] }),
90+
})
91+
}
92+
93+
it.each(['allow', 'allow_chat', 'always_allow', 'skip'] as const)(
94+
'records the generic %s decision without changing execution state',
95+
async (decision) => {
96+
recordToolPermissionDecision.mockResolvedValueOnce({
97+
toolCallId: 'tool-1',
98+
runId: 'run-1',
99+
toolName: 'run_workflow',
100+
status: 'pending',
101+
permissionDecision: decision,
102+
permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'),
103+
})
104+
105+
const response = await POST(createRequest(decision))
106+
107+
expect(response.status).toBe(200)
108+
expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision)
109+
expect(publishToolPermissionDecision).toHaveBeenCalledWith(
110+
expect.objectContaining({ toolCallId: 'tool-1', decision })
111+
)
112+
}
113+
)
114+
115+
it('uses the same decision path for non-workflow tools', async () => {
116+
const toolName = 'function_execute'
117+
const decision = 'allow'
118+
getAsyncToolCall.mockResolvedValueOnce({
119+
toolCallId: 'tool-1',
120+
runId: 'run-1',
121+
toolName,
122+
status: 'pending',
123+
permissionDecision: null,
124+
})
125+
recordToolPermissionDecision.mockResolvedValueOnce({
126+
toolCallId: 'tool-1',
127+
runId: 'run-1',
128+
toolName,
129+
status: 'pending',
130+
permissionDecision: decision,
131+
permissionDecidedAt: new Date('2026-08-01T00:00:00.000Z'),
132+
})
133+
134+
const response = await POST(createRequest(decision))
135+
136+
expect(response.status).toBe(200)
137+
expect(recordToolPermissionDecision).toHaveBeenCalledWith('tool-1', decision)
138+
})
139+
})

0 commit comments

Comments
 (0)