diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index dad34e321f..86cb7e639a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -237,6 +237,16 @@ function isSpecialToolCall(toolCall: CopilotToolCall): boolean { return workflowOperationTools.includes(toolCall.name) } +/** + * Checks if a tool is an integration tool (server-side executed, not a client tool) + */ +function isIntegrationTool(toolName: string): boolean { + // Check if it's NOT a client tool (not in CLASS_TOOL_METADATA and not in registered tools) + const isClientTool = !!CLASS_TOOL_METADATA[toolName] + const isRegisteredTool = !!getRegisteredTools()[toolName] + return !isClientTool && !isRegisteredTool +} + function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { const instance = getClientTool(toolCall.id) let hasInterrupt = !!instance?.getInterruptDisplays?.() @@ -251,7 +261,26 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { } } catch {} } - return hasInterrupt && toolCall.state === 'pending' + + // Show buttons for client tools with interrupts + if (hasInterrupt && toolCall.state === 'pending') { + return true + } + + // Also show buttons for integration tools in pending state (they need user confirmation) + // But NOT if the tool is auto-allowed (it will auto-execute) + const mode = useCopilotStore.getState().mode + const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name) + if ( + mode === 'build' && + isIntegrationTool(toolCall.name) && + toolCall.state === 'pending' && + !isAutoAllowed + ) { + return true + } + + return false } async function handleRun( @@ -261,6 +290,40 @@ async function handleRun( editedParams?: any ) { const instance = getClientTool(toolCall.id) + + // Handle integration tools (server-side execution) + if (!instance && isIntegrationTool(toolCall.name)) { + // Set executing state immediately for UI feedback + setToolCallState(toolCall, 'executing') + onStateChange?.('executing') + try { + await useCopilotStore.getState().executeIntegrationTool(toolCall.id) + // Note: executeIntegrationTool handles success/error state updates internally + } catch (e) { + // If executeIntegrationTool throws, ensure we update state to error + setToolCallState(toolCall, 'error', { error: e instanceof Error ? e.message : String(e) }) + onStateChange?.('error') + // Notify backend about the error so agent doesn't hang + try { + await fetch('/api/copilot/tools/mark-complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: toolCall.id, + name: toolCall.name, + status: 500, + message: e instanceof Error ? e.message : 'Tool execution failed', + data: { error: e instanceof Error ? e.message : String(e) }, + }), + }) + } catch { + // Last resort: log error if we can't notify backend + console.error('[handleRun] Failed to notify backend of tool error:', toolCall.id) + } + } + return + } + if (!instance) return try { const mergedParams = @@ -272,12 +335,51 @@ async function handleRun( await instance.handleAccept?.(mergedParams) onStateChange?.('executing') } catch (e) { - setToolCallState(toolCall, 'errored', { error: e instanceof Error ? e.message : String(e) }) + setToolCallState(toolCall, 'error', { error: e instanceof Error ? e.message : String(e) }) } } async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onStateChange?: any) { const instance = getClientTool(toolCall.id) + + // Handle integration tools (skip by marking as rejected and notifying backend) + if (!instance && isIntegrationTool(toolCall.name)) { + setToolCallState(toolCall, 'rejected') + onStateChange?.('rejected') + + // Notify backend that tool was skipped - this is CRITICAL for the agent to continue + // Retry up to 3 times if the notification fails + let notified = false + for (let attempt = 0; attempt < 3 && !notified; attempt++) { + try { + const res = await fetch('/api/copilot/tools/mark-complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: toolCall.id, + name: toolCall.name, + status: 400, + message: 'Tool execution skipped by user', + data: { skipped: true, reason: 'user_skipped' }, + }), + }) + if (res.ok) { + notified = true + } + } catch (e) { + // Wait briefly before retry + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + } + + if (!notified) { + console.error('[handleSkip] Failed to notify backend after 3 attempts:', toolCall.id) + } + return + } + if (instance) { try { await instance.handleReject?.() @@ -346,20 +448,60 @@ function RunSkipButtons({ }) { const [isProcessing, setIsProcessing] = useState(false) const [buttonsHidden, setButtonsHidden] = useState(false) - const { setToolCallState } = useCopilotStore() + const actionInProgressRef = useRef(false) + const { setToolCallState, addAutoAllowedTool } = useCopilotStore() const instance = getClientTool(toolCall.id) const interruptDisplays = instance?.getInterruptDisplays?.() - const acceptLabel = interruptDisplays?.accept?.text || 'Run' + const isIntegration = isIntegrationTool(toolCall.name) + + // For integration tools: Allow, Always Allow, Skip + // For client tools with interrupts: Run, Skip (or custom labels) + const acceptLabel = isIntegration ? 'Allow' : interruptDisplays?.accept?.text || 'Run' const rejectLabel = interruptDisplays?.reject?.text || 'Skip' const onRun = async () => { + // Prevent race condition - check ref synchronously + if (actionInProgressRef.current) return + actionInProgressRef.current = true setIsProcessing(true) setButtonsHidden(true) try { await handleRun(toolCall, setToolCallState, onStateChange, editedParams) } finally { setIsProcessing(false) + actionInProgressRef.current = false + } + } + + const onAlwaysAllow = async () => { + // Prevent race condition - check ref synchronously + if (actionInProgressRef.current) return + actionInProgressRef.current = true + setIsProcessing(true) + setButtonsHidden(true) + try { + // Add to auto-allowed list first + await addAutoAllowedTool(toolCall.name) + // Then execute + await handleRun(toolCall, setToolCallState, onStateChange, editedParams) + } finally { + setIsProcessing(false) + actionInProgressRef.current = false + } + } + + const onSkip = async () => { + // Prevent race condition - check ref synchronously + if (actionInProgressRef.current) return + actionInProgressRef.current = true + setIsProcessing(true) + setButtonsHidden(true) + try { + await handleSkip(toolCall, setToolCallState, onStateChange) + } finally { + setIsProcessing(false) + actionInProgressRef.current = false } } @@ -371,14 +513,13 @@ function RunSkipButtons({ {isProcessing ? : null} {acceptLabel} - + )} + @@ -402,12 +543,19 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: toolCall.name === 'run_workflow') const [expanded, setExpanded] = useState(isExpandablePending) + const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false) // State for editable parameters const params = (toolCall as any).parameters || (toolCall as any).input || toolCall.params || {} const [editedParams, setEditedParams] = useState(params) const paramsRef = useRef(params) + // Check if this integration tool is auto-allowed + // Subscribe to autoAllowedTools so we re-render when it changes + const autoAllowedTools = useCopilotStore((s) => s.autoAllowedTools) + const { removeAutoAllowedTool } = useCopilotStore() + const isAutoAllowed = isIntegrationTool(toolCall.name) && autoAllowedTools.includes(toolCall.name) + // Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change) useEffect(() => { if (JSON.stringify(params) !== JSON.stringify(paramsRef.current)) { @@ -798,15 +946,40 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: // Special handling for set_environment_variables - always stacked, always expanded if (toolCall.name === 'set_environment_variables' && toolCall.state === 'pending') { + const isEnvVarsClickable = isAutoAllowed + + const handleEnvVarsClick = () => { + if (isAutoAllowed) { + setShowRemoveAutoAllow((prev) => !prev) + } + } + return (
- +
+ +
{renderPendingDetails()}
+ {showRemoveAutoAllow && isAutoAllowed && ( +
+ +
+ )} {showButtons && ( { + if (isAutoAllowed) { + setShowRemoveAutoAllow((prev) => !prev) + } + } return (
- +
+ +
{code && (
)} + {showRemoveAutoAllow && isAutoAllowed && ( +
+ +
+ )} {showButtons && ( { + if (isExpandableTool) { + setExpanded((e) => !e) + } else if (isAutoAllowed) { + setShowRemoveAutoAllow((prev) => !prev) + } + } + return (
-
{ - if (isExpandableTool) setExpanded((e) => !e) - }} - > +
{isExpandableTool && expanded &&
{renderPendingDetails()}
} + {showRemoveAutoAllow && isAutoAllowed && ( +
+ +
+ )} {showButtons ? (