Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()
Expand All @@ -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(
Expand All @@ -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 =
Expand All @@ -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?.()
Expand Down Expand Up @@ -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
}
}

Expand All @@ -371,14 +513,13 @@ function RunSkipButtons({
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
{acceptLabel}
</Button>
<Button
onClick={async () => {
setButtonsHidden(true)
await handleSkip(toolCall, setToolCallState, onStateChange)
}}
disabled={isProcessing}
variant='default'
>
{isIntegration && (
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
{isProcessing ? <Loader2 className='mr-1 h-3 w-3 animate-spin' /> : null}
Always Allow
</Button>
)}
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
{rejectLabel}
</Button>
</div>
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 (
<div className='w-full'>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
<div className={isEnvVarsClickable ? 'cursor-pointer' : ''} onClick={handleEnvVarsClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[#3a3d41] text-sm dark:text-[#939393]'
/>
</div>
<div className='mt-[8px]'>{renderPendingDetails()}</div>
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-[8px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
>
Remove from Always Allowed
</Button>
</div>
)}
{showButtons && (
<RunSkipButtons
toolCall={toolCall}
Expand All @@ -821,20 +994,47 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
// Special rendering for function_execute - show code block
if (toolCall.name === 'function_execute') {
const code = params.code || ''
const isFunctionExecuteClickable = isAutoAllowed

const handleFunctionExecuteClick = () => {
if (isAutoAllowed) {
setShowRemoveAutoAllow((prev) => !prev)
}
}

return (
<div className='w-full'>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={false}
className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
/>
<div
className={isFunctionExecuteClickable ? 'cursor-pointer' : ''}
onClick={handleFunctionExecuteClick}
>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={false}
className='font-[470] font-season text-[#939393] text-sm dark:text-[#939393]'
/>
</div>
{code && (
<div className='mt-2'>
<Code.Viewer code={code} language='javascript' showGutter />
</div>
)}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-[8px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
>
Remove from Always Allowed
</Button>
</div>
)}
{showButtons && (
<RunSkipButtons
toolCall={toolCall}
Expand All @@ -846,14 +1046,20 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
)
}

// Determine if tool name should be clickable (expandable tools or auto-allowed integration tools)
const isToolNameClickable = isExpandableTool || isAutoAllowed

const handleToolNameClick = () => {
if (isExpandableTool) {
setExpanded((e) => !e)
} else if (isAutoAllowed) {
setShowRemoveAutoAllow((prev) => !prev)
}
}

return (
<div className='w-full'>
<div
className={isExpandableTool ? 'cursor-pointer' : ''}
onClick={() => {
if (isExpandableTool) setExpanded((e) => !e)
}}
>
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
Expand All @@ -862,6 +1068,21 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
/>
</div>
{isExpandableTool && expanded && <div>{renderPendingDetails()}</div>}
{showRemoveAutoAllow && isAutoAllowed && (
<div className='mt-[8px]'>
<Button
onClick={async () => {
await removeAutoAllowedTool(toolCall.name)
setShowRemoveAutoAllow(false)
forceUpdate({})
}}
variant='default'
className='text-xs'
>
Remove from Always Allowed
</Button>
</div>
)}
{showButtons ? (
<RunSkipButtons
toolCall={toolCall}
Expand Down
Loading