mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12c4c2d44f | ||
|
|
656dfafb8f | ||
|
|
9f604f379e | ||
|
|
75da06adf8 | ||
|
|
929a352edb | ||
|
|
c7b473f55f |
@@ -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 ? <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>
|
||||
@@ -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 (
|
||||
<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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -89,6 +89,18 @@ vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@trigger.dev/sdk', () => ({
|
||||
task: vi.fn(() => ({ trigger: vi.fn() })),
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
batchTrigger: vi.fn().mockResolvedValue([{ id: 'mock-task-id' }]),
|
||||
},
|
||||
runs: {
|
||||
retrieve: vi.fn().mockResolvedValue({ id: 'mock-run-id', status: 'COMPLETED' }),
|
||||
},
|
||||
configure: vi.fn(),
|
||||
}))
|
||||
|
||||
const originalConsoleError = console.error
|
||||
const originalConsoleWarn = console.warn
|
||||
|
||||
|
||||
Reference in New Issue
Block a user