Skip to content

Commit 75da06a

Browse files
authored
fix(copilot): fix hanging tool calls (#2218)
1 parent c7b473f commit 75da06a

File tree

1 file changed

+113
-9
lines changed
  • apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call

1 file changed

+113
-9
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,16 @@ function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
237237
return workflowOperationTools.includes(toolCall.name)
238238
}
239239

240+
/**
241+
* Checks if a tool is an integration tool (server-side executed, not a client tool)
242+
*/
243+
function isIntegrationTool(toolName: string): boolean {
244+
// Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools)
245+
const isClientTool = !!CLASS_TOOL_METADATA[toolName]
246+
const isRegisteredTool = !!getRegisteredTools()[toolName]
247+
return !isClientTool && !isRegisteredTool
248+
}
249+
240250
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
241251
const instance = getClientTool(toolCall.id)
242252
let hasInterrupt = !!instance?.getInterruptDisplays?.()
@@ -251,7 +261,19 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
251261
}
252262
} catch {}
253263
}
254-
return hasInterrupt && toolCall.state === 'pending'
264+
265+
// Show buttons for client tools with interrupts
266+
if (hasInterrupt && toolCall.state === 'pending') {
267+
return true
268+
}
269+
270+
// Also show buttons for integration tools in pending state (they need user confirmation)
271+
const mode = useCopilotStore.getState().mode
272+
if (mode === 'build' && isIntegrationTool(toolCall.name) && toolCall.state === 'pending') {
273+
return true
274+
}
275+
276+
return false
255277
}
256278

257279
async function handleRun(
@@ -261,6 +283,18 @@ async function handleRun(
261283
editedParams?: any
262284
) {
263285
const instance = getClientTool(toolCall.id)
286+
287+
// Handle integration tools (server-side execution)
288+
if (!instance && isIntegrationTool(toolCall.name)) {
289+
try {
290+
onStateChange?.('executing')
291+
await useCopilotStore.getState().executeIntegrationTool(toolCall.id)
292+
} catch (e) {
293+
setToolCallState(toolCall, 'errored', { error: e instanceof Error ? e.message : String(e) })
294+
}
295+
return
296+
}
297+
264298
if (!instance) return
265299
try {
266300
const mergedParams =
@@ -278,6 +312,27 @@ async function handleRun(
278312

279313
async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any) {
280314
const instance = getClientTool(toolCall.id)
315+
316+
// Handle integration tools (skip by marking as rejected and notifying backend)
317+
if (!instance && isIntegrationTool(toolCall.name)) {
318+
setToolCallState(toolCall, 'rejected')
319+
onStateChange?.('rejected')
320+
// Notify backend that tool was skipped
321+
try {
322+
await fetch('/api/copilot/tools/mark-complete', {
323+
method: 'POST',
324+
headers: { 'Content-Type': 'application/json' },
325+
body: JSON.stringify({
326+
id: toolCall.id,
327+
name: toolCall.name,
328+
status: 400,
329+
message: 'Tool execution skipped by user',
330+
}),
331+
})
332+
} catch {}
333+
return
334+
}
335+
281336
if (instance) {
282337
try {
283338
await instance.handleReject?.()
@@ -346,11 +401,15 @@ function RunSkipButtons({
346401
}) {
347402
const [isProcessing, setIsProcessing] = useState(false)
348403
const [buttonsHidden, setButtonsHidden] = useState(false)
349-
const { setToolCallState } = useCopilotStore()
404+
const { setToolCallState, addAutoAllowedTool } = useCopilotStore()
350405

351406
const instance = getClientTool(toolCall.id)
352407
const interruptDisplays = instance?.getInterruptDisplays?.()
353-
const acceptLabel = interruptDisplays?.accept?.text || 'Run'
408+
const isIntegration = isIntegrationTool(toolCall.name)
409+
410+
// For integration tools: Allow, Always Allow, Skip
411+
// For client tools with interrupts: Run, Skip (or custom labels)
412+
const acceptLabel = isIntegration ? 'Allow' : interruptDisplays?.accept?.text || 'Run'
354413
const rejectLabel = interruptDisplays?.reject?.text || 'Skip'
355414

356415
const onRun = async () => {
@@ -363,6 +422,19 @@ function RunSkipButtons({
363422
}
364423
}
365424

425+
const onAlwaysAllow = async () => {
426+
setIsProcessing(true)
427+
setButtonsHidden(true)
428+
try {
429+
// Add to auto-allowed list
430+
await addAutoAllowedTool(toolCall.name)
431+
// Then execute
432+
await handleRun(toolCall, setToolCallState, onStateChange, editedParams)
433+
} finally {
434+
setIsProcessing(false)
435+
}
436+
}
437+
366438
if (buttonsHidden) return null
367439

368440
return (
@@ -371,6 +443,12 @@ function RunSkipButtons({
371443
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
372444
{acceptLabel}
373445
</Button>
446+
{isIntegration && (
447+
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
448+
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
449+
Always Allow
450+
</Button>
451+
)}
374452
<Button
375453
onClick={async () => {
376454
setButtonsHidden(true)
@@ -402,12 +480,17 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
402480
toolCall.name === 'run_workflow')
403481

404482
const [expanded, setExpanded] = useState(isExpandablePending)
483+
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
405484

406485
// State for editable parameters
407486
const params = (toolCall as any).parameters || (toolCall as any).input || toolCall.params || {}
408487
const [editedParams, setEditedParams] = useState(params)
409488
const paramsRef = useRef(params)
410489

490+
// Check if this integration tool is auto-allowed
491+
const { isToolAutoAllowed, removeAutoAllowedTool } = useCopilotStore()
492+
const isAutoAllowed = isIntegrationTool(toolCall.name) && isToolAutoAllowed(toolCall.name)
493+
411494
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
412495
useEffect(() => {
413496
if (JSON.stringify(params) !== JSON.stringify(paramsRef.current)) {
@@ -846,14 +929,20 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
846929
)
847930
}
848931

932+
// Determine if tool name should be clickable (expandable tools or auto-allowed integration tools)
933+
const isToolNameClickable = isExpandableTool || isAutoAllowed
934+
935+
const handleToolNameClick = () => {
936+
if (isExpandableTool) {
937+
setExpanded((e) => !e)
938+
} else if (isAutoAllowed) {
939+
setShowRemoveAutoAllow((prev) => !prev)
940+
}
941+
}
942+
849943
return (
850944
<div className='w-full'>
851-
<div
852-
className={isExpandableTool ? 'cursor-pointer' : ''}
853-
onClick={() => {
854-
if (isExpandableTool) setExpanded((e) => !e)
855-
}}
856-
>
945+
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
857946
<ShimmerOverlayText
858947
text={displayName}
859948
active={isLoadingState}
@@ -862,6 +951,21 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
862951
/>
863952
</div>
864953
{isExpandableTool && expanded && <div>{renderPendingDetails()}</div>}
954+
{showRemoveAutoAllow && isAutoAllowed && (
955+
<div className='mt-[8px]'>
956+
<Button
957+
onClick={async () => {
958+
await removeAutoAllowedTool(toolCall.name)
959+
setShowRemoveAutoAllow(false)
960+
forceUpdate({})
961+
}}
962+
variant='default'
963+
className='text-xs'
964+
>
965+
Remove from Always Allowed
966+
</Button>
967+
</div>
968+
)}
865969
{showButtons ? (
866970
<RunSkipButtons
867971
toolCall={toolCall}

0 commit comments

Comments
 (0)