mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 07:24:55 -05:00
SSE interface
This commit is contained in:
@@ -1,145 +1,81 @@
|
||||
import { db } from '@sim/db'
|
||||
import { settings } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
|
||||
import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('CopilotAutoAllowedToolsAPI')
|
||||
|
||||
/**
|
||||
* GET - Fetch user's auto-allowed integration tools
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [userSettings] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userSettings) {
|
||||
const autoAllowedTools = (userSettings.copilotAutoAllowedTools as string[]) || []
|
||||
return NextResponse.json({ autoAllowedTools })
|
||||
}
|
||||
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotAutoAllowedTools: [],
|
||||
})
|
||||
|
||||
return NextResponse.json({ autoAllowedTools: [] })
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch auto-allowed tools', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
function copilotHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Add a tool to the auto-allowed list
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.toolId || typeof body.toolId !== 'string') {
|
||||
return NextResponse.json({ error: 'toolId must be a string' }, { status: 400 })
|
||||
}
|
||||
|
||||
const toolId = body.toolId
|
||||
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
|
||||
if (!currentTools.includes(toolId)) {
|
||||
const updatedTools = [...currentTools, toolId]
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
copilotAutoAllowedTools: updatedTools,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
|
||||
logger.info('Added tool to auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, autoAllowedTools: currentTools })
|
||||
}
|
||||
|
||||
await db.insert(settings).values({
|
||||
id: userId,
|
||||
userId,
|
||||
copilotAutoAllowedTools: [toolId],
|
||||
})
|
||||
|
||||
logger.info('Created settings and added tool to auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: [toolId] })
|
||||
} catch (error) {
|
||||
logger.error('Failed to add auto-allowed tool', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Remove a tool from the auto-allowed list
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const toolIdFromQuery = new URL(request.url).searchParams.get('toolId') || undefined
|
||||
const toolIdFromBody = await request
|
||||
.json()
|
||||
.then((body) => (typeof body?.toolId === 'string' ? body.toolId : undefined))
|
||||
.catch(() => undefined)
|
||||
const toolId = toolIdFromBody || toolIdFromQuery
|
||||
if (!toolId) {
|
||||
return NextResponse.json({ error: 'toolId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
const res = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
|
||||
method: 'DELETE',
|
||||
headers: copilotHeaders(),
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
toolId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
logger.warn('Failed to remove auto-allowed tool via copilot backend', {
|
||||
status: res.status,
|
||||
userId,
|
||||
toolId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: payload?.error || 'Failed to remove auto-allowed tool',
|
||||
autoAllowedTools: [],
|
||||
},
|
||||
{ status: res.status }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const toolId = searchParams.get('toolId')
|
||||
|
||||
if (!toolId) {
|
||||
return NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (existing) {
|
||||
const currentTools = (existing.copilotAutoAllowedTools as string[]) || []
|
||||
const updatedTools = currentTools.filter((t) => t !== toolId)
|
||||
|
||||
await db
|
||||
.update(settings)
|
||||
.set({
|
||||
copilotAutoAllowedTools: updatedTools,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
|
||||
logger.info('Removed tool from auto-allowed list', { userId, toolId })
|
||||
return NextResponse.json({ success: true, autoAllowedTools: updatedTools })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, autoAllowedTools: [] })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
autoAllowedTools: Array.isArray(payload?.autoAllowedTools) ? payload.autoAllowedTools : [],
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove auto-allowed tool', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
logger.error('Error removing auto-allowed tool', {
|
||||
userId,
|
||||
toolId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to remove auto-allowed tool',
|
||||
autoAllowedTools: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { REDIS_TOOL_CALL_PREFIX, REDIS_TOOL_CALL_TTL_SECONDS } from '@/lib/copilot/constants'
|
||||
import {
|
||||
REDIS_TOOL_CALL_PREFIX,
|
||||
REDIS_TOOL_CALL_TTL_SECONDS,
|
||||
SIM_AGENT_API_URL,
|
||||
} from '@/lib/copilot/constants'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
@@ -10,6 +14,7 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
type NotificationStatus,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
|
||||
const logger = createLogger('CopilotConfirmAPI')
|
||||
@@ -21,6 +26,8 @@ const ConfirmationSchema = z.object({
|
||||
errorMap: () => ({ message: 'Invalid notification status' }),
|
||||
}),
|
||||
message: z.string().optional(), // Optional message for background moves or additional context
|
||||
toolName: z.string().optional(),
|
||||
remember: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -57,6 +64,44 @@ async function updateToolCallStatus(
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutoAllowedToolPreference(userId: string, toolName: string): Promise<boolean> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SIM_AGENT_API_URL}/api/tool-preferences/auto-allowed`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
toolId: toolName,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to persist auto-allowed tool preference', {
|
||||
userId,
|
||||
toolName,
|
||||
status: response.status,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error persisting auto-allowed tool preference', {
|
||||
userId,
|
||||
toolName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/copilot/confirm
|
||||
* Update tool call status (Accept/Reject)
|
||||
@@ -74,7 +119,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
|
||||
const { toolCallId, status, message, toolName, remember } = ConfirmationSchema.parse(body)
|
||||
|
||||
// Update the tool call status in Redis
|
||||
const updated = await updateToolCallStatus(toolCallId, status, message)
|
||||
@@ -90,14 +135,22 @@ export async function POST(req: NextRequest) {
|
||||
return createBadRequestResponse('Failed to update tool call status or tool call not found')
|
||||
}
|
||||
|
||||
const duration = tracker.getDuration()
|
||||
let rememberSaved = false
|
||||
if (status === 'accepted' && remember === true && toolName && authenticatedUserId) {
|
||||
rememberSaved = await saveAutoAllowedToolPreference(authenticatedUserId, toolName)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
|
||||
toolCallId,
|
||||
status,
|
||||
})
|
||||
}
|
||||
if (remember === true) {
|
||||
response.rememberSaved = rememberSaved
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
const duration = tracker.getDuration()
|
||||
|
||||
|
||||
@@ -1243,11 +1243,6 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
||||
)
|
||||
})
|
||||
|
||||
/** Checks if a tool is server-side executed (not a client tool) */
|
||||
function isIntegrationTool(toolName: string): boolean {
|
||||
return !TOOL_DISPLAY_REGISTRY[toolName]
|
||||
}
|
||||
|
||||
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||
if (!toolCall.name || toolCall.name === 'unknown_tool') {
|
||||
return false
|
||||
@@ -1257,59 +1252,96 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// Never show buttons for tools the user has marked as always-allowed
|
||||
if (useCopilotStore.getState().isToolAutoAllowed(toolCall.name)) {
|
||||
if (toolCall.ui?.showInterrupt !== true) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasInterrupt = !!TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.interrupt
|
||||
if (hasInterrupt) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Integration tools (user-installed) always require approval
|
||||
if (isIntegrationTool(toolCall.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
const toolCallLogger = createLogger('CopilotToolCall')
|
||||
|
||||
async function sendToolDecision(
|
||||
toolCallId: string,
|
||||
status: 'accepted' | 'rejected' | 'background'
|
||||
status: 'accepted' | 'rejected' | 'background',
|
||||
options?: {
|
||||
toolName?: string
|
||||
remember?: boolean
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await fetch('/api/copilot/confirm', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolCallId, status }),
|
||||
body: JSON.stringify({
|
||||
toolCallId,
|
||||
status,
|
||||
...(options?.toolName ? { toolName: options.toolName } : {}),
|
||||
...(options?.remember ? { remember: true } : {}),
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
toolCallLogger.warn('Failed to send tool decision', {
|
||||
toolCallId,
|
||||
status,
|
||||
remember: options?.remember === true,
|
||||
toolName: options?.toolName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAutoAllowedToolPreference(toolName: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`/api/copilot/auto-allowed-tools?toolId=${encodeURIComponent(toolName)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
toolCallLogger.warn('Failed to remove auto-allowed tool preference', {
|
||||
toolName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ToolUiAction = NonNullable<NonNullable<CopilotToolCall['ui']>['actions']>[number]
|
||||
|
||||
function actionDecision(action: ToolUiAction): 'accepted' | 'rejected' | 'background' {
|
||||
const id = action.id.toLowerCase()
|
||||
if (id.includes('background')) return 'background'
|
||||
if (action.kind === 'reject') return 'rejected'
|
||||
return 'accepted'
|
||||
}
|
||||
|
||||
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
|
||||
if (toolCall.execution?.target === 'sim_client_capability') {
|
||||
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
|
||||
}
|
||||
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
|
||||
}
|
||||
|
||||
async function handleRun(
|
||||
toolCall: CopilotToolCall,
|
||||
setToolCallState: any,
|
||||
onStateChange?: any,
|
||||
editedParams?: any
|
||||
editedParams?: any,
|
||||
options?: {
|
||||
remember?: boolean
|
||||
}
|
||||
) {
|
||||
setToolCallState(toolCall, 'executing', editedParams ? { params: editedParams } : undefined)
|
||||
onStateChange?.('executing')
|
||||
await sendToolDecision(toolCall.id, 'accepted')
|
||||
await sendToolDecision(toolCall.id, 'accepted', {
|
||||
toolName: toolCall.name,
|
||||
remember: options?.remember === true,
|
||||
})
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback
|
||||
// (block pulsing, console logs, stop button). The server defers execution
|
||||
// for these tools; the client reports back via mark-complete.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)) {
|
||||
if (isClientRunCapability(toolCall)) {
|
||||
const params = editedParams || toolCall.params || {}
|
||||
executeRunToolOnClient(toolCall.id, toolCall.name, params)
|
||||
}
|
||||
@@ -1322,6 +1354,9 @@ async function handleSkip(toolCall: CopilotToolCall, setToolCallState: any, onSt
|
||||
}
|
||||
|
||||
function getDisplayName(toolCall: CopilotToolCall): string {
|
||||
if (toolCall.ui?.phaseLabel) return toolCall.ui.phaseLabel
|
||||
if (toolCall.ui?.title) return `${getStateVerb(toolCall.state)} ${toolCall.ui.title}`
|
||||
|
||||
const fromStore = (toolCall as any).display?.text
|
||||
if (fromStore) return fromStore
|
||||
const registryEntry = TOOL_DISPLAY_REGISTRY[toolCall.name]
|
||||
@@ -1366,53 +1401,37 @@ function RunSkipButtons({
|
||||
toolCall,
|
||||
onStateChange,
|
||||
editedParams,
|
||||
actions,
|
||||
}: {
|
||||
toolCall: CopilotToolCall
|
||||
onStateChange?: (state: any) => void
|
||||
editedParams?: any
|
||||
actions: ToolUiAction[]
|
||||
}) {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [buttonsHidden, setButtonsHidden] = useState(false)
|
||||
const actionInProgressRef = useRef(false)
|
||||
const { setToolCallState, addAutoAllowedTool } = useCopilotStore()
|
||||
const { setToolCallState } = useCopilotStore()
|
||||
|
||||
const onRun = async () => {
|
||||
const onAction = async (action: ToolUiAction) => {
|
||||
// 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 {
|
||||
await addAutoAllowedTool(toolCall.name)
|
||||
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)
|
||||
const decision = actionDecision(action)
|
||||
if (decision === 'accepted') {
|
||||
await handleRun(toolCall, setToolCallState, onStateChange, editedParams, {
|
||||
remember: action.remember === true,
|
||||
})
|
||||
} else if (decision === 'rejected') {
|
||||
await handleSkip(toolCall, setToolCallState, onStateChange)
|
||||
} else {
|
||||
setToolCallState(toolCall, ClientToolCallState.background)
|
||||
onStateChange?.('background')
|
||||
await sendToolDecision(toolCall.id, 'background')
|
||||
}
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
actionInProgressRef.current = false
|
||||
@@ -1421,23 +1440,22 @@ function RunSkipButtons({
|
||||
|
||||
if (buttonsHidden) return null
|
||||
|
||||
// Show "Always Allow" for all tools that require confirmation
|
||||
const showAlwaysAllow = true
|
||||
|
||||
// Standardized buttons for all interrupt tools: Allow, Always Allow, Skip
|
||||
return (
|
||||
<div className='mt-[10px] flex gap-[6px]'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||
</Button>
|
||||
{showAlwaysAllow && (
|
||||
<Button onClick={onAlwaysAllow} disabled={isProcessing} variant='default'>
|
||||
{isProcessing ? 'Allowing...' : 'Always Allow'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSkip} disabled={isProcessing} variant='default'>
|
||||
Skip
|
||||
</Button>
|
||||
{actions.map((action, index) => {
|
||||
const variant =
|
||||
action.kind === 'reject' ? 'default' : action.remember ? 'default' : 'tertiary'
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
onClick={() => onAction(action)}
|
||||
disabled={isProcessing}
|
||||
variant={variant}
|
||||
>
|
||||
{isProcessing && index === 0 ? 'Working...' : action.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1454,10 +1472,16 @@ export function ToolCall({
|
||||
const liveToolCall = useCopilotStore((s) =>
|
||||
effectiveId ? s.toolCallsById[effectiveId] : undefined
|
||||
)
|
||||
const toolCall = liveToolCall || toolCallProp
|
||||
|
||||
// Guard: nothing to render without a toolCall
|
||||
if (!toolCall) return null
|
||||
const rawToolCall = liveToolCall || toolCallProp
|
||||
const hasRealToolCall = !!rawToolCall
|
||||
const toolCall: CopilotToolCall =
|
||||
rawToolCall ||
|
||||
({
|
||||
id: effectiveId || '',
|
||||
name: '',
|
||||
state: ClientToolCallState.generating,
|
||||
params: {},
|
||||
} as CopilotToolCall)
|
||||
|
||||
const isExpandablePending =
|
||||
toolCall?.state === 'pending' &&
|
||||
@@ -1465,17 +1489,15 @@ export function ToolCall({
|
||||
|
||||
const [expanded, setExpanded] = useState(isExpandablePending)
|
||||
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
|
||||
const [autoAllowRemovedForCall, setAutoAllowRemovedForCall] = 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
|
||||
const { removeAutoAllowedTool, setToolCallState } = useCopilotStore()
|
||||
const isAutoAllowed = useCopilotStore(
|
||||
(s) => isIntegrationTool(toolCall.name) && s.isToolAutoAllowed(toolCall.name)
|
||||
)
|
||||
const { setToolCallState } = useCopilotStore()
|
||||
const isAutoAllowed = toolCall.ui?.autoAllowed === true && !autoAllowRemovedForCall
|
||||
|
||||
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
|
||||
useEffect(() => {
|
||||
@@ -1485,6 +1507,14 @@ export function ToolCall({
|
||||
}
|
||||
}, [params])
|
||||
|
||||
useEffect(() => {
|
||||
setAutoAllowRemovedForCall(false)
|
||||
setShowRemoveAutoAllow(false)
|
||||
}, [toolCall.id])
|
||||
|
||||
// Guard: nothing to render without a toolCall
|
||||
if (!hasRealToolCall) return null
|
||||
|
||||
// Skip rendering some internal tools
|
||||
if (
|
||||
toolCall.name === 'checkoff_todo' ||
|
||||
@@ -1496,7 +1526,9 @@ export function ToolCall({
|
||||
return null
|
||||
|
||||
// Special rendering for subagent tools - show as thinking text with tool calls at top level
|
||||
const isSubagentTool = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true
|
||||
const isSubagentTool =
|
||||
toolCall.execution?.target === 'go_subagent' ||
|
||||
TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig?.subagent === true
|
||||
|
||||
// For ALL subagent tools, don't show anything until we have blocks with content
|
||||
if (isSubagentTool) {
|
||||
@@ -1523,28 +1555,6 @@ export function ToolCall({
|
||||
)
|
||||
}
|
||||
|
||||
// Get current mode from store to determine if we should render integration tools
|
||||
const mode = useCopilotStore.getState().mode
|
||||
|
||||
// Check if this is a completed/historical tool call (not pending/executing)
|
||||
// Use string comparison to handle both enum values and string values from DB
|
||||
const stateStr = String(toolCall.state)
|
||||
const isCompletedToolCall =
|
||||
stateStr === 'success' ||
|
||||
stateStr === 'error' ||
|
||||
stateStr === 'rejected' ||
|
||||
stateStr === 'aborted'
|
||||
|
||||
// Allow rendering if:
|
||||
// 1. Tool is in TOOL_DISPLAY_REGISTRY (client tools), OR
|
||||
// 2. We're in build mode (integration tools are executed server-side), OR
|
||||
// 3. Tool call is already completed (historical - should always render)
|
||||
const isClientTool = !!TOOL_DISPLAY_REGISTRY[toolCall.name]
|
||||
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
|
||||
|
||||
if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) {
|
||||
return null
|
||||
}
|
||||
const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig
|
||||
// Check if tool has params table config (meaning it's expandable)
|
||||
const hasParamsTable = !!toolUIConfig?.paramsTable
|
||||
@@ -1554,6 +1564,14 @@ export function ToolCall({
|
||||
toolCall.name === 'make_api_request' ||
|
||||
toolCall.name === 'set_global_workflow_variables'
|
||||
|
||||
const interruptActions =
|
||||
(toolCall.ui?.actions && toolCall.ui.actions.length > 0
|
||||
? toolCall.ui.actions
|
||||
: [
|
||||
{ id: 'allow_once', label: 'Allow', kind: 'accept' as const },
|
||||
{ id: 'allow_always', label: 'Always Allow', kind: 'accept' as const, remember: true },
|
||||
{ id: 'reject', label: 'Skip', kind: 'reject' as const },
|
||||
]) as ToolUiAction[]
|
||||
const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall)
|
||||
|
||||
// Check UI config for secondary action - only show for current message tool calls
|
||||
@@ -2011,9 +2029,12 @@ export function ToolCall({
|
||||
<div className='mt-[10px]'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
const removed = await removeAutoAllowedToolPreference(toolCall.name)
|
||||
if (removed) {
|
||||
setAutoAllowRemovedForCall(true)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
}
|
||||
}}
|
||||
variant='default'
|
||||
className='text-xs'
|
||||
@@ -2027,6 +2048,7 @@ export function ToolCall({
|
||||
toolCall={toolCall}
|
||||
onStateChange={handleStateChange}
|
||||
editedParams={editedParams}
|
||||
actions={interruptActions}
|
||||
/>
|
||||
)}
|
||||
{/* Render subagent content as thinking text */}
|
||||
@@ -2072,9 +2094,12 @@ export function ToolCall({
|
||||
<div className='mt-[10px]'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
const removed = await removeAutoAllowedToolPreference(toolCall.name)
|
||||
if (removed) {
|
||||
setAutoAllowRemovedForCall(true)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
}
|
||||
}}
|
||||
variant='default'
|
||||
className='text-xs'
|
||||
@@ -2088,6 +2113,7 @@ export function ToolCall({
|
||||
toolCall={toolCall}
|
||||
onStateChange={handleStateChange}
|
||||
editedParams={editedParams}
|
||||
actions={interruptActions}
|
||||
/>
|
||||
)}
|
||||
{/* Render subagent content as thinking text */}
|
||||
@@ -2133,9 +2159,12 @@ export function ToolCall({
|
||||
<div className='mt-[10px]'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
const removed = await removeAutoAllowedToolPreference(toolCall.name)
|
||||
if (removed) {
|
||||
setAutoAllowRemovedForCall(true)
|
||||
setShowRemoveAutoAllow(false)
|
||||
forceUpdate({})
|
||||
}
|
||||
}}
|
||||
variant='default'
|
||||
className='text-xs'
|
||||
@@ -2149,6 +2178,7 @@ export function ToolCall({
|
||||
toolCall={toolCall}
|
||||
onStateChange={handleStateChange}
|
||||
editedParams={editedParams}
|
||||
actions={interruptActions}
|
||||
/>
|
||||
) : showMoveToBackground ? (
|
||||
<div className='mt-[10px]'>
|
||||
|
||||
@@ -113,7 +113,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
clearPlanArtifact,
|
||||
savePlanArtifact,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
resumeActiveStream,
|
||||
} = useCopilotStore()
|
||||
|
||||
@@ -125,8 +124,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
resumeActiveStream,
|
||||
})
|
||||
|
||||
@@ -12,8 +12,6 @@ interface UseCopilotInitializationProps {
|
||||
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
||||
loadChats: (forceRefresh?: boolean) => Promise<void>
|
||||
loadAvailableModels: () => Promise<void>
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
currentChat: any
|
||||
isSendingMessage: boolean
|
||||
resumeActiveStream: () => Promise<boolean>
|
||||
}
|
||||
@@ -32,8 +30,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
loadAvailableModels,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
resumeActiveStream,
|
||||
} = props
|
||||
@@ -120,17 +116,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
})
|
||||
}, [isSendingMessage, resumeActiveStream])
|
||||
|
||||
/** Load auto-allowed tools once on mount - runs immediately, independent of workflow */
|
||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasLoadedAutoAllowedToolsRef.current) {
|
||||
hasLoadedAutoAllowedToolsRef.current = true
|
||||
loadAutoAllowedTools().catch((err) => {
|
||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||
})
|
||||
}
|
||||
}, [loadAutoAllowedTools])
|
||||
|
||||
/** Load available models once on mount */
|
||||
const hasLoadedModelsRef = useRef(false)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { COPILOT_CONFIRM_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
|
||||
import { STREAM_STORAGE_KEY } from '@/lib/copilot/constants'
|
||||
import { asRecord } from '@/lib/copilot/orchestrator/sse-utils'
|
||||
import type { SSEEvent } from '@/lib/copilot/orchestrator/types'
|
||||
import {
|
||||
@@ -35,6 +35,80 @@ function isWorkflowEditToolCall(toolName?: string, params?: Record<string, unkno
|
||||
return typeof params?.proposalId === 'string' && params.proposalId.length > 0
|
||||
}
|
||||
|
||||
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
|
||||
if (toolCall.execution?.target === 'sim_client_capability') {
|
||||
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
|
||||
}
|
||||
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
|
||||
}
|
||||
|
||||
function mapServerStateToClientState(state: unknown): ClientToolCallState {
|
||||
switch (String(state || '')) {
|
||||
case 'generating':
|
||||
return ClientToolCallState.generating
|
||||
case 'pending':
|
||||
case 'awaiting_approval':
|
||||
return ClientToolCallState.pending
|
||||
case 'executing':
|
||||
return ClientToolCallState.executing
|
||||
case 'success':
|
||||
return ClientToolCallState.success
|
||||
case 'rejected':
|
||||
case 'skipped':
|
||||
return ClientToolCallState.rejected
|
||||
case 'aborted':
|
||||
return ClientToolCallState.aborted
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return ClientToolCallState.error
|
||||
default:
|
||||
return ClientToolCallState.pending
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolUiMetadata(data: Record<string, unknown>): CopilotToolCall['ui'] | undefined {
|
||||
const ui = asRecord(data.ui)
|
||||
if (!ui || Object.keys(ui).length === 0) return undefined
|
||||
const autoAllowedFromUi = ui.autoAllowed === true
|
||||
const autoAllowedFromData = data.autoAllowed === true
|
||||
return {
|
||||
title: typeof ui.title === 'string' ? ui.title : undefined,
|
||||
phaseLabel: typeof ui.phaseLabel === 'string' ? ui.phaseLabel : undefined,
|
||||
icon: typeof ui.icon === 'string' ? ui.icon : undefined,
|
||||
showInterrupt: ui.showInterrupt === true,
|
||||
showRemember: ui.showRemember === true,
|
||||
autoAllowed: autoAllowedFromUi || autoAllowedFromData,
|
||||
actions: Array.isArray(ui.actions)
|
||||
? ui.actions
|
||||
.map((action) => {
|
||||
const a = asRecord(action)
|
||||
const id = typeof a.id === 'string' ? a.id : undefined
|
||||
const label = typeof a.label === 'string' ? a.label : undefined
|
||||
const kind: 'accept' | 'reject' = a.kind === 'reject' ? 'reject' : 'accept'
|
||||
if (!id || !label) return null
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
remember: a.remember === true,
|
||||
}
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => !!a)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolExecutionMetadata(
|
||||
data: Record<string, unknown>
|
||||
): CopilotToolCall['execution'] | undefined {
|
||||
const execution = asRecord(data.execution)
|
||||
if (!execution || Object.keys(execution).length === 0) return undefined
|
||||
return {
|
||||
target: typeof execution.target === 'string' ? execution.target : undefined,
|
||||
capabilityId: typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowChangeApplyCall(toolName?: string, params?: Record<string, unknown>): boolean {
|
||||
if (toolName !== 'workflow_change') return false
|
||||
const mode = typeof params?.mode === 'string' ? params.mode.toLowerCase() : ''
|
||||
@@ -67,23 +141,6 @@ function extractOperationListFromResultPayload(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an auto-accept confirmation to the server for auto-allowed tools.
|
||||
* The server-side orchestrator polls Redis for this decision.
|
||||
*/
|
||||
export function sendAutoAcceptConfirmation(toolCallId: string): void {
|
||||
fetch(COPILOT_CONFIRM_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolCallId, status: 'accepted' }),
|
||||
}).catch((error) => {
|
||||
logger.warn('Failed to send auto-accept confirmation', {
|
||||
toolCallId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function writeActiveStreamToStorage(info: CopilotStreamInfo | null): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -285,12 +342,25 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
try {
|
||||
const eventData = asRecord(data?.data)
|
||||
const toolCallId: string | undefined =
|
||||
data?.toolCallId || (eventData.id as string | undefined)
|
||||
data?.toolCallId ||
|
||||
(eventData.id as string | undefined) ||
|
||||
(eventData.callId as string | undefined)
|
||||
const success: boolean | undefined = data?.success
|
||||
const failedDependency: boolean = data?.failedDependency === true
|
||||
const resultObj = asRecord(data?.result)
|
||||
const skipped: boolean = resultObj.skipped === true
|
||||
if (!toolCallId) return
|
||||
const uiMetadata = extractToolUiMetadata(eventData)
|
||||
const executionMetadata = extractToolExecutionMetadata(eventData)
|
||||
const serverState = (eventData.state as string | undefined) || undefined
|
||||
const targetState = serverState
|
||||
? mapServerStateToClientState(serverState)
|
||||
: success
|
||||
? ClientToolCallState.success
|
||||
: failedDependency || skipped
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const resultPayload = asRecord(data?.result || eventData.result || eventData.data || data?.data)
|
||||
const { toolCallsById } = get()
|
||||
const current = toolCallsById[toolCallId]
|
||||
let paramsForCurrentToolCall: Record<string, unknown> | undefined = current?.params
|
||||
@@ -302,15 +372,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const targetState = success
|
||||
? ClientToolCallState.success
|
||||
: failedDependency || skipped
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const resultPayload = asRecord(
|
||||
data?.result || eventData.result || eventData.data || data?.data
|
||||
)
|
||||
|
||||
if (
|
||||
targetState === ClientToolCallState.success &&
|
||||
isWorkflowChangeApplyCall(current.name, paramsForCurrentToolCall)
|
||||
@@ -327,6 +388,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const updatedMap = { ...toolCallsById }
|
||||
updatedMap[toolCallId] = {
|
||||
...current,
|
||||
ui: uiMetadata || current.ui,
|
||||
execution: executionMetadata || current.execution,
|
||||
params: paramsForCurrentToolCall,
|
||||
state: targetState,
|
||||
display: resolveToolDisplay(
|
||||
@@ -542,6 +605,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
toolCall: {
|
||||
...b.toolCall,
|
||||
params: paramsForBlock,
|
||||
ui: uiMetadata || b.toolCall?.ui,
|
||||
execution: executionMetadata || b.toolCall?.execution,
|
||||
state: targetState,
|
||||
display: resolveToolDisplay(
|
||||
b.toolCall?.name,
|
||||
@@ -565,7 +630,9 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
try {
|
||||
const errorData = asRecord(data?.data)
|
||||
const toolCallId: string | undefined =
|
||||
data?.toolCallId || (errorData.id as string | undefined)
|
||||
data?.toolCallId ||
|
||||
(errorData.id as string | undefined) ||
|
||||
(errorData.callId as string | undefined)
|
||||
const failedDependency: boolean = data?.failedDependency === true
|
||||
if (!toolCallId) return
|
||||
const { toolCallsById } = get()
|
||||
@@ -578,12 +645,18 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const targetState = failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const targetState = errorData.state
|
||||
? mapServerStateToClientState(errorData.state)
|
||||
: failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const uiMetadata = extractToolUiMetadata(errorData)
|
||||
const executionMetadata = extractToolExecutionMetadata(errorData)
|
||||
const updatedMap = { ...toolCallsById }
|
||||
updatedMap[toolCallId] = {
|
||||
...current,
|
||||
ui: uiMetadata || current.ui,
|
||||
execution: executionMetadata || current.execution,
|
||||
state: targetState,
|
||||
display: resolveToolDisplay(current.name, targetState, current.id, current.params),
|
||||
}
|
||||
@@ -598,13 +671,19 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
isBackgroundState(b.toolCall?.state)
|
||||
)
|
||||
break
|
||||
const targetState = failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const targetState = errorData.state
|
||||
? mapServerStateToClientState(errorData.state)
|
||||
: failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const uiMetadata = extractToolUiMetadata(errorData)
|
||||
const executionMetadata = extractToolExecutionMetadata(errorData)
|
||||
context.contentBlocks[i] = {
|
||||
...b,
|
||||
toolCall: {
|
||||
...b.toolCall,
|
||||
ui: uiMetadata || b.toolCall?.ui,
|
||||
execution: executionMetadata || b.toolCall?.execution,
|
||||
state: targetState,
|
||||
display: resolveToolDisplay(
|
||||
b.toolCall?.name,
|
||||
@@ -625,19 +704,26 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
}
|
||||
},
|
||||
tool_generating: (data, context, get, set) => {
|
||||
const { toolCallId, toolName } = data
|
||||
const eventData = asRecord(data?.data)
|
||||
const toolCallId =
|
||||
data?.toolCallId ||
|
||||
(eventData.id as string | undefined) ||
|
||||
(eventData.callId as string | undefined)
|
||||
const toolName =
|
||||
data?.toolName ||
|
||||
(eventData.name as string | undefined) ||
|
||||
(eventData.toolName as string | undefined)
|
||||
if (!toolCallId || !toolName) return
|
||||
const { toolCallsById } = get()
|
||||
|
||||
if (!toolCallsById[toolCallId]) {
|
||||
const isAutoAllowed = get().isToolAutoAllowed(toolName)
|
||||
const initialState = isAutoAllowed
|
||||
? ClientToolCallState.executing
|
||||
: ClientToolCallState.pending
|
||||
const initialState = ClientToolCallState.generating
|
||||
const tc: CopilotToolCall = {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
state: initialState,
|
||||
ui: extractToolUiMetadata(eventData),
|
||||
execution: extractToolExecutionMetadata(eventData),
|
||||
display: resolveToolDisplay(toolName, initialState, toolCallId),
|
||||
}
|
||||
const updated = { ...toolCallsById, [toolCallId]: tc }
|
||||
@@ -650,17 +736,27 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
},
|
||||
tool_call: (data, context, get, set) => {
|
||||
const toolData = asRecord(data?.data)
|
||||
const id: string | undefined = (toolData.id as string | undefined) || data?.toolCallId
|
||||
const name: string | undefined = (toolData.name as string | undefined) || data?.toolName
|
||||
const id: string | undefined =
|
||||
(toolData.id as string | undefined) ||
|
||||
(toolData.callId as string | undefined) ||
|
||||
data?.toolCallId
|
||||
const name: string | undefined =
|
||||
(toolData.name as string | undefined) ||
|
||||
(toolData.toolName as string | undefined) ||
|
||||
data?.toolName
|
||||
if (!id) return
|
||||
const args = toolData.arguments as Record<string, unknown> | undefined
|
||||
const isPartial = toolData.partial === true
|
||||
const uiMetadata = extractToolUiMetadata(toolData)
|
||||
const executionMetadata = extractToolExecutionMetadata(toolData)
|
||||
const serverState = toolData.state
|
||||
const { toolCallsById } = get()
|
||||
|
||||
const existing = toolCallsById[id]
|
||||
const toolName = name || existing?.name || 'unknown_tool'
|
||||
const isAutoAllowed = get().isToolAutoAllowed(toolName)
|
||||
let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending
|
||||
let initialState = serverState
|
||||
? mapServerStateToClientState(serverState)
|
||||
: ClientToolCallState.pending
|
||||
|
||||
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
|
||||
if (
|
||||
@@ -675,6 +771,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
...existing,
|
||||
name: toolName,
|
||||
state: initialState,
|
||||
ui: uiMetadata || existing.ui,
|
||||
execution: executionMetadata || existing.execution,
|
||||
...(args ? { params: args } : {}),
|
||||
display: resolveToolDisplay(toolName, initialState, id, args || existing.params),
|
||||
}
|
||||
@@ -682,6 +780,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
id,
|
||||
name: toolName,
|
||||
state: initialState,
|
||||
ui: uiMetadata,
|
||||
execution: executionMetadata,
|
||||
...(args ? { params: args } : {}),
|
||||
display: resolveToolDisplay(toolName, initialState, id, args),
|
||||
}
|
||||
@@ -696,20 +796,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-allowed tools: send confirmation to the server so it can proceed
|
||||
// without waiting for the user to click "Allow".
|
||||
if (isAutoAllowed) {
|
||||
sendAutoAcceptConfirmation(id)
|
||||
}
|
||||
const shouldInterrupt = next.ui?.showInterrupt === true
|
||||
|
||||
// Client-executable run tools: execute on the client for real-time feedback
|
||||
// (block pulsing, console logs, stop button). The server defers execution
|
||||
// for these tools in interactive mode; the client reports back via mark-complete.
|
||||
if (
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName) &&
|
||||
initialState === ClientToolCallState.executing
|
||||
) {
|
||||
executeRunToolOnClient(id, toolName, args || existing?.params || {})
|
||||
// Client-run capability: execution is delegated to the browser.
|
||||
// We run immediately only when no interrupt is required.
|
||||
if (isClientRunCapability(next) && !shouldInterrupt) {
|
||||
executeRunToolOnClient(id, toolName, args || next.params || {})
|
||||
}
|
||||
|
||||
// OAuth: dispatch event to open the OAuth connect modal
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import {
|
||||
type SSEHandler,
|
||||
sendAutoAcceptConfirmation,
|
||||
sseHandlers,
|
||||
updateStreamingMessage,
|
||||
} from './handlers'
|
||||
@@ -26,6 +25,80 @@ type StoreSet = (
|
||||
partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)
|
||||
) => void
|
||||
|
||||
function mapServerStateToClientState(state: unknown): ClientToolCallState {
|
||||
switch (String(state || '')) {
|
||||
case 'generating':
|
||||
return ClientToolCallState.generating
|
||||
case 'pending':
|
||||
case 'awaiting_approval':
|
||||
return ClientToolCallState.pending
|
||||
case 'executing':
|
||||
return ClientToolCallState.executing
|
||||
case 'success':
|
||||
return ClientToolCallState.success
|
||||
case 'rejected':
|
||||
case 'skipped':
|
||||
return ClientToolCallState.rejected
|
||||
case 'aborted':
|
||||
return ClientToolCallState.aborted
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return ClientToolCallState.error
|
||||
default:
|
||||
return ClientToolCallState.pending
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolUiMetadata(data: Record<string, unknown>): CopilotToolCall['ui'] | undefined {
|
||||
const ui = asRecord(data.ui)
|
||||
if (!ui || Object.keys(ui).length === 0) return undefined
|
||||
const autoAllowedFromUi = ui.autoAllowed === true
|
||||
const autoAllowedFromData = data.autoAllowed === true
|
||||
return {
|
||||
title: typeof ui.title === 'string' ? ui.title : undefined,
|
||||
phaseLabel: typeof ui.phaseLabel === 'string' ? ui.phaseLabel : undefined,
|
||||
icon: typeof ui.icon === 'string' ? ui.icon : undefined,
|
||||
showInterrupt: ui.showInterrupt === true,
|
||||
showRemember: ui.showRemember === true,
|
||||
autoAllowed: autoAllowedFromUi || autoAllowedFromData,
|
||||
actions: Array.isArray(ui.actions)
|
||||
? ui.actions
|
||||
.map((action) => {
|
||||
const a = asRecord(action)
|
||||
const id = typeof a.id === 'string' ? a.id : undefined
|
||||
const label = typeof a.label === 'string' ? a.label : undefined
|
||||
const kind: 'accept' | 'reject' = a.kind === 'reject' ? 'reject' : 'accept'
|
||||
if (!id || !label) return null
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
remember: a.remember === true,
|
||||
}
|
||||
})
|
||||
.filter((a): a is NonNullable<typeof a> => !!a)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolExecutionMetadata(
|
||||
data: Record<string, unknown>
|
||||
): CopilotToolCall['execution'] | undefined {
|
||||
const execution = asRecord(data.execution)
|
||||
if (!execution || Object.keys(execution).length === 0) return undefined
|
||||
return {
|
||||
target: typeof execution.target === 'string' ? execution.target : undefined,
|
||||
capabilityId: typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function isClientRunCapability(toolCall: CopilotToolCall): boolean {
|
||||
if (toolCall.execution?.target === 'sim_client_capability') {
|
||||
return toolCall.execution.capabilityId === 'workflow.run' || !toolCall.execution.capabilityId
|
||||
}
|
||||
return CLIENT_EXECUTABLE_RUN_TOOLS.has(toolCall.name)
|
||||
}
|
||||
|
||||
function isWorkflowChangeApplyCall(toolCall: CopilotToolCall): boolean {
|
||||
if (toolCall.name !== 'workflow_change') return false
|
||||
const params = (toolCall.params || {}) as Record<string, unknown>
|
||||
@@ -199,6 +272,8 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
const name: string | undefined = (toolData.name as string | undefined) || data?.toolName
|
||||
if (!id || !name) return
|
||||
const isPartial = toolData.partial === true
|
||||
const uiMetadata = extractToolUiMetadata(toolData)
|
||||
const executionMetadata = extractToolExecutionMetadata(toolData)
|
||||
|
||||
let args: Record<string, unknown> | undefined = (toolData.arguments || toolData.input) as
|
||||
| Record<string, unknown>
|
||||
@@ -234,9 +309,10 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
const existingToolCall =
|
||||
existingIndex >= 0 ? context.subAgentToolCalls[parentToolCallId][existingIndex] : undefined
|
||||
|
||||
// Auto-allowed tools skip pending state to avoid flashing interrupt buttons
|
||||
const isAutoAllowed = get().isToolAutoAllowed(name)
|
||||
let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending
|
||||
const serverState = toolData.state
|
||||
let initialState = serverState
|
||||
? mapServerStateToClientState(serverState)
|
||||
: ClientToolCallState.pending
|
||||
|
||||
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
|
||||
if (
|
||||
@@ -250,6 +326,8 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
id,
|
||||
name,
|
||||
state: initialState,
|
||||
ui: uiMetadata,
|
||||
execution: executionMetadata,
|
||||
...(args ? { params: args } : {}),
|
||||
display: resolveToolDisplay(name, initialState, id, args),
|
||||
}
|
||||
@@ -276,16 +354,11 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-allowed tools: send confirmation to the server so it can proceed
|
||||
// without waiting for the user to click "Allow".
|
||||
if (isAutoAllowed) {
|
||||
sendAutoAcceptConfirmation(id)
|
||||
}
|
||||
const shouldInterrupt = subAgentToolCall.ui?.showInterrupt === true
|
||||
|
||||
// Client-executable run tools: if auto-allowed, execute immediately for
|
||||
// real-time feedback. For non-auto-allowed, the user must click "Allow"
|
||||
// first — handleRun in tool-call.tsx triggers executeRunToolOnClient.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(name) && isAutoAllowed) {
|
||||
// Client-run capability: execution is delegated to the browser.
|
||||
// Execute immediately only for non-interrupting calls.
|
||||
if (isClientRunCapability(subAgentToolCall) && !shouldInterrupt) {
|
||||
executeRunToolOnClient(id, name, args || {})
|
||||
}
|
||||
},
|
||||
@@ -310,7 +383,14 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
if (!context.subAgentToolCalls[parentToolCallId]) return
|
||||
if (!context.subAgentBlocks[parentToolCallId]) return
|
||||
|
||||
const targetState = success ? ClientToolCallState.success : ClientToolCallState.error
|
||||
const serverState = resultData.state
|
||||
const targetState = serverState
|
||||
? mapServerStateToClientState(serverState)
|
||||
: success
|
||||
? ClientToolCallState.success
|
||||
: ClientToolCallState.error
|
||||
const uiMetadata = extractToolUiMetadata(resultData)
|
||||
const executionMetadata = extractToolExecutionMetadata(resultData)
|
||||
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
|
||||
(tc: CopilotToolCall) => tc.id === toolCallId
|
||||
)
|
||||
@@ -338,6 +418,8 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
const updatedSubAgentToolCall = {
|
||||
...existing,
|
||||
params: nextParams,
|
||||
ui: uiMetadata || existing.ui,
|
||||
execution: executionMetadata || existing.execution,
|
||||
state: targetState,
|
||||
display: resolveToolDisplay(existing.name, targetState, toolCallId, nextParams),
|
||||
}
|
||||
|
||||
@@ -101,9 +101,6 @@ export const COPILOT_CHECKPOINTS_API_PATH = '/api/copilot/checkpoints'
|
||||
/** POST — revert to a checkpoint. */
|
||||
export const COPILOT_CHECKPOINTS_REVERT_API_PATH = '/api/copilot/checkpoints/revert'
|
||||
|
||||
/** GET/POST/DELETE — manage auto-allowed tools. */
|
||||
export const COPILOT_AUTO_ALLOWED_TOOLS_API_PATH = '/api/copilot/auto-allowed-tools'
|
||||
|
||||
/** GET — fetch dynamically available copilot models. */
|
||||
export const COPILOT_MODELS_API_PATH = '/api/copilot/models'
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
export const INTERRUPT_TOOL_NAMES = [
|
||||
'set_global_workflow_variables',
|
||||
'run_workflow',
|
||||
'run_workflow_until_block',
|
||||
'run_from_block',
|
||||
'run_block',
|
||||
'manage_mcp_tool',
|
||||
'manage_custom_tool',
|
||||
'deploy_mcp',
|
||||
'deploy_chat',
|
||||
'deploy_api',
|
||||
'create_workspace_mcp_server',
|
||||
'set_environment_variables',
|
||||
'make_api_request',
|
||||
'oauth_request_access',
|
||||
'navigate_ui',
|
||||
'knowledge_base',
|
||||
'generate_api_key',
|
||||
] as const
|
||||
|
||||
export const INTERRUPT_TOOL_SET = new Set<string>(INTERRUPT_TOOL_NAMES)
|
||||
|
||||
export const SUBAGENT_TOOL_NAMES = [
|
||||
'debug',
|
||||
'edit',
|
||||
'build',
|
||||
'plan',
|
||||
'test',
|
||||
'deploy',
|
||||
'auth',
|
||||
'research',
|
||||
'knowledge',
|
||||
'custom_tool',
|
||||
'tour',
|
||||
'info',
|
||||
'workflow',
|
||||
'evaluate',
|
||||
'superagent',
|
||||
'discovery',
|
||||
] as const
|
||||
|
||||
export const SUBAGENT_TOOL_SET = new Set<string>(SUBAGENT_TOOL_NAMES)
|
||||
|
||||
/**
|
||||
* Respond tools are internal to the copilot's subagent system.
|
||||
* They're used by subagents to signal completion and should NOT be executed by the sim side.
|
||||
* The copilot backend handles these internally.
|
||||
*/
|
||||
export const RESPOND_TOOL_NAMES = [
|
||||
'plan_respond',
|
||||
'edit_respond',
|
||||
'build_respond',
|
||||
'debug_respond',
|
||||
'info_respond',
|
||||
'research_respond',
|
||||
'deploy_respond',
|
||||
'superagent_respond',
|
||||
'discovery_respond',
|
||||
'tour_respond',
|
||||
'auth_respond',
|
||||
'workflow_respond',
|
||||
'knowledge_respond',
|
||||
'custom_tool_respond',
|
||||
'test_respond',
|
||||
] as const
|
||||
|
||||
export const RESPOND_TOOL_SET = new Set<string>(RESPOND_TOOL_NAMES)
|
||||
@@ -1,17 +1,12 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants'
|
||||
import { RESPOND_TOOL_SET, SUBAGENT_TOOL_SET } from '@/lib/copilot/orchestrator/config'
|
||||
import {
|
||||
asRecord,
|
||||
getEventData,
|
||||
markToolResultSeen,
|
||||
wasToolResultSeen,
|
||||
} from '@/lib/copilot/orchestrator/sse-utils'
|
||||
import {
|
||||
isIntegrationTool,
|
||||
isToolAvailableOnSimSide,
|
||||
markToolComplete,
|
||||
} from '@/lib/copilot/orchestrator/tool-executor'
|
||||
import { markToolComplete } from '@/lib/copilot/orchestrator/tool-executor'
|
||||
import type {
|
||||
ContentBlock,
|
||||
ExecutionContext,
|
||||
@@ -22,7 +17,6 @@ import type {
|
||||
} from '@/lib/copilot/orchestrator/types'
|
||||
import {
|
||||
executeToolAndReport,
|
||||
isInterruptToolName,
|
||||
waitForToolCompletion,
|
||||
waitForToolDecision,
|
||||
} from './tool-execution'
|
||||
@@ -41,6 +35,113 @@ const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
|
||||
'run_block',
|
||||
])
|
||||
|
||||
function mapServerStateToToolStatus(state: unknown): ToolCallState['status'] {
|
||||
switch (String(state || '')) {
|
||||
case 'generating':
|
||||
case 'pending':
|
||||
case 'awaiting_approval':
|
||||
return 'pending'
|
||||
case 'executing':
|
||||
return 'executing'
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'rejected':
|
||||
case 'skipped':
|
||||
return 'rejected'
|
||||
case 'aborted':
|
||||
return 'skipped'
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'error'
|
||||
default:
|
||||
return 'pending'
|
||||
}
|
||||
}
|
||||
|
||||
function getExecutionTarget(
|
||||
toolData: Record<string, unknown>,
|
||||
toolName: string
|
||||
): { target: string; capabilityId?: string } {
|
||||
const execution = asRecord(toolData.execution)
|
||||
if (typeof execution.target === 'string' && execution.target.length > 0) {
|
||||
return {
|
||||
target: execution.target,
|
||||
capabilityId:
|
||||
typeof execution.capabilityId === 'string' ? execution.capabilityId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback only when metadata is missing.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
|
||||
return { target: 'sim_client_capability', capabilityId: 'workflow.run' }
|
||||
}
|
||||
return { target: 'sim_server' }
|
||||
}
|
||||
|
||||
function needsApproval(toolData: Record<string, unknown>): boolean {
|
||||
const ui = asRecord(toolData.ui)
|
||||
return ui.showInterrupt === true
|
||||
}
|
||||
|
||||
async function waitForClientCapabilityAndReport(
|
||||
toolCall: ToolCallState,
|
||||
options: OrchestratorOptions,
|
||||
logScope: string
|
||||
): Promise<void> {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCall.id,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
|
||||
if (completion?.status === 'background') {
|
||||
toolCall.status = 'skipped'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
202,
|
||||
completion.message || 'Tool execution moved to background',
|
||||
{ background: true }
|
||||
).catch((err) => {
|
||||
logger.error(`markToolComplete fire-and-forget failed (${logScope} background)`, {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCall.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (completion?.status === 'rejected') {
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(toolCall.id, toolCall.name, 400, completion.message || 'Tool execution rejected')
|
||||
.catch((err) => {
|
||||
logger.error(`markToolComplete fire-and-forget failed (${logScope} rejected)`, {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCall.id)
|
||||
return
|
||||
}
|
||||
|
||||
const success = completion?.status === 'success'
|
||||
toolCall.status = success ? 'success' : 'error'
|
||||
toolCall.endTime = Date.now()
|
||||
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
|
||||
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
|
||||
logger.error(`markToolComplete fire-and-forget failed (${logScope})`, {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCall.id)
|
||||
}
|
||||
|
||||
// Normalization + dedupe helpers live in sse-utils to keep server/client in sync.
|
||||
|
||||
function inferToolSuccess(data: Record<string, unknown> | undefined): {
|
||||
@@ -85,7 +186,11 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
const { success, hasResultData, hasError } = inferToolSuccess(data)
|
||||
|
||||
current.status = success ? 'success' : 'error'
|
||||
current.status = data?.state
|
||||
? mapServerStateToToolStatus(data.state)
|
||||
: success
|
||||
? 'success'
|
||||
: 'error'
|
||||
current.endTime = Date.now()
|
||||
if (hasResultData) {
|
||||
current.result = {
|
||||
@@ -104,7 +209,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
if (!toolCallId) return
|
||||
const current = context.toolCalls.get(toolCallId)
|
||||
if (!current) return
|
||||
current.status = 'error'
|
||||
current.status = data?.state ? mapServerStateToToolStatus(data.state) : 'error'
|
||||
current.error = (data?.error as string | undefined) || 'Tool execution failed'
|
||||
current.endTime = Date.now()
|
||||
},
|
||||
@@ -121,7 +226,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
context.toolCalls.set(toolCallId, {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
status: 'pending',
|
||||
status: data?.state ? mapServerStateToToolStatus(data.state) : 'pending',
|
||||
startTime: Date.now(),
|
||||
})
|
||||
}
|
||||
@@ -156,7 +261,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
context.toolCalls.set(toolCallId, {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
status: 'pending',
|
||||
status: toolData.state ? mapServerStateToToolStatus(toolData.state) : 'pending',
|
||||
params: args,
|
||||
startTime: Date.now(),
|
||||
})
|
||||
@@ -170,83 +275,29 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const toolCall = context.toolCalls.get(toolCallId)
|
||||
if (!toolCall) return
|
||||
|
||||
// Subagent tools are executed by the copilot backend, not sim side.
|
||||
if (SUBAGENT_TOOL_SET.has(toolName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Respond tools are internal to copilot's subagent system - skip execution.
|
||||
// The copilot backend handles these internally to signal subagent completion.
|
||||
if (RESPOND_TOOL_SET.has(toolName)) {
|
||||
toolCall.status = 'success'
|
||||
toolCall.endTime = Date.now()
|
||||
toolCall.result = {
|
||||
success: true,
|
||||
output: 'Internal respond tool - handled by copilot backend',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const isInterruptTool = isInterruptToolName(toolName)
|
||||
const execution = getExecutionTarget(toolData, toolName)
|
||||
const isInteractive = options.interactive === true
|
||||
// Integration tools (user-installed) also require approval in interactive mode
|
||||
const needsApproval = isInterruptTool || isIntegrationTool(toolName)
|
||||
const requiresApproval = isInteractive && needsApproval(toolData)
|
||||
if (toolData.state) {
|
||||
toolCall.status = mapServerStateToToolStatus(toolData.state)
|
||||
}
|
||||
|
||||
if (needsApproval && isInteractive) {
|
||||
if (requiresApproval) {
|
||||
const decision = await waitForToolDecision(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
if (decision?.status === 'accepted' || decision?.status === 'success') {
|
||||
// Client-executable run tools: defer execution to the browser client.
|
||||
// The client calls executeWorkflowWithFullLogging for real-time feedback
|
||||
// (block pulsing, logs, stop button) and reports completion via
|
||||
// /api/copilot/confirm with status success/error. We poll Redis for
|
||||
// that completion signal, then fire-and-forget markToolComplete to Go.
|
||||
if (CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
if (completion?.status === 'background') {
|
||||
toolCall.status = 'skipped'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
202,
|
||||
completion.message || 'Tool execution moved to background',
|
||||
{ background: true }
|
||||
).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (run tool background)', {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
return
|
||||
}
|
||||
const success = completion?.status === 'success'
|
||||
toolCall.status = success ? 'success' : 'error'
|
||||
toolCall.endTime = Date.now()
|
||||
const msg =
|
||||
completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
|
||||
// Fire-and-forget: tell Go backend the tool is done
|
||||
// (must NOT await — see deadlock note in executeToolAndReport)
|
||||
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (run tool)', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
if (execution.target === 'sim_client_capability' && isInteractive) {
|
||||
await waitForClientCapabilityAndReport(toolCall, options, 'run tool')
|
||||
return
|
||||
}
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
if (execution.target === 'sim_server' || execution.target === 'sim_client_capability') {
|
||||
if (options.autoExecuteTools !== false) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -308,7 +359,15 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
if (options.autoExecuteTools !== false) {
|
||||
if (execution.target === 'sim_client_capability' && isInteractive) {
|
||||
await waitForClientCapabilityAndReport(toolCall, options, 'run tool')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(execution.target === 'sim_server' || execution.target === 'sim_client_capability') &&
|
||||
options.autoExecuteTools !== false
|
||||
) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
},
|
||||
@@ -410,7 +469,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
const toolCall: ToolCallState = {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
status: 'pending',
|
||||
status: toolData.state ? mapServerStateToToolStatus(toolData.state) : 'pending',
|
||||
params: args,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
@@ -428,37 +487,26 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
if (isPartial) return
|
||||
|
||||
// Respond tools are internal to copilot's subagent system - skip execution.
|
||||
if (RESPOND_TOOL_SET.has(toolName)) {
|
||||
toolCall.status = 'success'
|
||||
toolCall.endTime = Date.now()
|
||||
toolCall.result = {
|
||||
success: true,
|
||||
output: 'Internal respond tool - handled by copilot backend',
|
||||
}
|
||||
return
|
||||
}
|
||||
const execution = getExecutionTarget(toolData, toolName)
|
||||
const isInteractive = options.interactive === true
|
||||
const requiresApproval = isInteractive && needsApproval(toolData)
|
||||
|
||||
// Tools that only exist on the Go backend (e.g. search_patterns,
|
||||
// search_errors, remember_debug) should NOT be re-executed on the Sim side.
|
||||
// The Go backend already executed them and will send its own tool_result
|
||||
// SSE event with the real outcome. Trying to execute them here would fail
|
||||
// with "Tool not found" and incorrectly mark the tool as failed.
|
||||
if (!isToolAvailableOnSimSide(toolName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Interrupt tools and integration tools (user-installed) require approval
|
||||
// in interactive mode, same as top-level handler.
|
||||
const needsSubagentApproval = isInterruptToolName(toolName) || isIntegrationTool(toolName)
|
||||
if (options.interactive === true && needsSubagentApproval) {
|
||||
if (requiresApproval) {
|
||||
const decision = await waitForToolDecision(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
if (decision?.status === 'accepted' || decision?.status === 'success') {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
if (execution.target === 'sim_client_capability' && isInteractive) {
|
||||
await waitForClientCapabilityAndReport(toolCall, options, 'subagent run tool')
|
||||
return
|
||||
}
|
||||
if (execution.target === 'sim_server' || execution.target === 'sim_client_capability') {
|
||||
if (options.autoExecuteTools !== false) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (decision?.status === 'rejected' || decision?.status === 'error') {
|
||||
@@ -517,66 +565,15 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Client-executable run tools in interactive mode: defer to client.
|
||||
// Same pattern as main handler: wait for client completion, then tell Go.
|
||||
if (options.interactive === true && CLIENT_EXECUTABLE_RUN_TOOLS.has(toolName)) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
if (completion?.status === 'rejected') {
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
400,
|
||||
completion.message || 'Tool execution rejected'
|
||||
).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (subagent run tool rejected)', {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
return
|
||||
}
|
||||
if (completion?.status === 'background') {
|
||||
toolCall.status = 'skipped'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
202,
|
||||
completion.message || 'Tool execution moved to background',
|
||||
{ background: true }
|
||||
).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (subagent run tool background)', {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
return
|
||||
}
|
||||
const success = completion?.status === 'success'
|
||||
toolCall.status = success ? 'success' : 'error'
|
||||
toolCall.endTime = Date.now()
|
||||
const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out')
|
||||
markToolComplete(toolCall.id, toolCall.name, success ? 200 : 500, msg).catch((err) => {
|
||||
logger.error('markToolComplete fire-and-forget failed (subagent run tool)', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
if (execution.target === 'sim_client_capability' && isInteractive) {
|
||||
await waitForClientCapabilityAndReport(toolCall, options, 'subagent run tool')
|
||||
return
|
||||
}
|
||||
|
||||
if (options.autoExecuteTools !== false) {
|
||||
if (
|
||||
(execution.target === 'sim_server' || execution.target === 'sim_client_capability') &&
|
||||
options.autoExecuteTools !== false
|
||||
) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
},
|
||||
@@ -596,7 +593,7 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
const { success, hasResultData, hasError } = inferToolSuccess(data)
|
||||
|
||||
const status = success ? 'success' : 'error'
|
||||
const status = data?.state ? mapServerStateToToolStatus(data.state) : success ? 'success' : 'error'
|
||||
const endTime = Date.now()
|
||||
const result = hasResultData ? { success, output: data?.result || data?.data } : undefined
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TOOL_DECISION_MAX_POLL_MS,
|
||||
TOOL_DECISION_POLL_BACKOFF,
|
||||
} from '@/lib/copilot/constants'
|
||||
import { INTERRUPT_TOOL_SET } from '@/lib/copilot/orchestrator/config'
|
||||
import { getToolConfirmation } from '@/lib/copilot/orchestrator/persistence'
|
||||
import {
|
||||
asRecord,
|
||||
@@ -21,10 +20,6 @@ import type {
|
||||
|
||||
const logger = createLogger('CopilotSseToolExecution')
|
||||
|
||||
export function isInterruptToolName(toolName: string): boolean {
|
||||
return INTERRUPT_TOOL_SET.has(toolName)
|
||||
}
|
||||
|
||||
export async function executeToolAndReport(
|
||||
toolCallId: string,
|
||||
context: StreamingContext,
|
||||
@@ -34,9 +29,11 @@ export async function executeToolAndReport(
|
||||
const toolCall = context.toolCalls.get(toolCallId)
|
||||
if (!toolCall) return
|
||||
|
||||
if (toolCall.status === 'executing') return
|
||||
const lockable = toolCall as typeof toolCall & { __simExecuting?: boolean }
|
||||
if (lockable.__simExecuting) return
|
||||
if (wasToolResultSeen(toolCall.id)) return
|
||||
|
||||
lockable.__simExecuting = true
|
||||
toolCall.status = 'executing'
|
||||
try {
|
||||
const result = await executeToolServerSide(toolCall, execContext)
|
||||
@@ -122,6 +119,8 @@ export async function executeToolAndReport(
|
||||
},
|
||||
}
|
||||
await options?.onEvent?.(errorEvent)
|
||||
} finally {
|
||||
delete lockable.__simExecuting
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { flushStreamingUpdates, stopStreamingUpdates } from '@/lib/copilot/client-sse/handlers'
|
||||
import type { ClientContentBlock, ClientStreamingContext } from '@/lib/copilot/client-sse/types'
|
||||
import {
|
||||
COPILOT_AUTO_ALLOWED_TOOLS_API_PATH,
|
||||
COPILOT_CHAT_API_PATH,
|
||||
COPILOT_CHAT_STREAM_API_PATH,
|
||||
COPILOT_CHECKPOINTS_API_PATH,
|
||||
@@ -149,41 +148,6 @@ function updateActiveStreamEventId(
|
||||
writeActiveStreamToStorage(next)
|
||||
}
|
||||
|
||||
const AUTO_ALLOWED_TOOLS_STORAGE_KEY = 'copilot_auto_allowed_tools'
|
||||
|
||||
function readAutoAllowedToolsFromStorage(): string[] | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTO_ALLOWED_TOOLS_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return null
|
||||
return parsed.filter((item): item is string => typeof item === 'string')
|
||||
} catch (error) {
|
||||
logger.warn('[AutoAllowedTools] Failed to read local cache', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeAutoAllowedToolsToStorage(tools: string[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(AUTO_ALLOWED_TOOLS_STORAGE_KEY, JSON.stringify(tools))
|
||||
} catch (error) {
|
||||
logger.warn('[AutoAllowedTools] Failed to write local cache', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isToolAutoAllowedByList(toolId: string, autoAllowedTools: string[]): boolean {
|
||||
if (!toolId) return false
|
||||
const normalizedTarget = toolId.trim()
|
||||
return autoAllowedTools.some((allowed) => allowed?.trim() === normalizedTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any lingering diff preview from a previous session.
|
||||
* Called lazily when the store is first activated (setWorkflowId).
|
||||
@@ -489,11 +453,6 @@ function prepareSendContext(
|
||||
.catch((err) => {
|
||||
logger.warn('[Copilot] Failed to load sensitive credential IDs', err)
|
||||
})
|
||||
get()
|
||||
.loadAutoAllowedTools()
|
||||
.catch((err) => {
|
||||
logger.warn('[Copilot] Failed to load auto-allowed tools', err)
|
||||
})
|
||||
|
||||
let newMessages: CopilotMessage[]
|
||||
if (revertState) {
|
||||
@@ -1046,8 +1005,6 @@ async function resumeFromLiveStream(
|
||||
return false
|
||||
}
|
||||
|
||||
const cachedAutoAllowedTools = readAutoAllowedToolsFromStorage()
|
||||
|
||||
// Initial state (subset required for UI/streaming)
|
||||
const initialState = {
|
||||
mode: 'build' as const,
|
||||
@@ -1082,8 +1039,6 @@ const initialState = {
|
||||
streamingPlanContent: '',
|
||||
toolCallsById: {} as Record<string, CopilotToolCall>,
|
||||
suppressAutoSelect: false,
|
||||
autoAllowedTools: cachedAutoAllowedTools ?? ([] as string[]),
|
||||
autoAllowedToolsLoaded: cachedAutoAllowedTools !== null,
|
||||
activeStream: null as CopilotStreamInfo | null,
|
||||
messageQueue: [] as import('./types').QueuedMessage[],
|
||||
suppressAbortContinueOption: false,
|
||||
@@ -1122,8 +1077,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
agentPrefetch: get().agentPrefetch,
|
||||
availableModels: get().availableModels,
|
||||
isLoadingModels: get().isLoadingModels,
|
||||
autoAllowedTools: get().autoAllowedTools,
|
||||
autoAllowedToolsLoaded: get().autoAllowedToolsLoaded,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1438,16 +1391,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
|
||||
// Send a message (streaming only)
|
||||
sendMessage: async (message: string, options = {}) => {
|
||||
if (!get().autoAllowedToolsLoaded) {
|
||||
try {
|
||||
await get().loadAutoAllowedTools()
|
||||
} catch (error) {
|
||||
logger.warn('[Copilot] Failed to preload auto-allowed tools before send', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const prepared = prepareSendContext(get, set, message, options as SendMessageOptionsInput)
|
||||
if (!prepared) return
|
||||
|
||||
@@ -2418,74 +2361,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
loadAutoAllowedTools: async () => {
|
||||
try {
|
||||
logger.debug('[AutoAllowedTools] Loading from API...')
|
||||
const res = await fetch(COPILOT_AUTO_ALLOWED_TOOLS_API_PATH)
|
||||
logger.debug('[AutoAllowedTools] Load response', { status: res.status, ok: res.ok })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const tools = data.autoAllowedTools ?? []
|
||||
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
|
||||
writeAutoAllowedToolsToStorage(tools)
|
||||
logger.debug('[AutoAllowedTools] Loaded successfully', { count: tools.length, tools })
|
||||
} else {
|
||||
set({ autoAllowedToolsLoaded: true })
|
||||
logger.warn('[AutoAllowedTools] Load failed with status', { status: res.status })
|
||||
}
|
||||
} catch (err) {
|
||||
set({ autoAllowedToolsLoaded: true })
|
||||
logger.error('[AutoAllowedTools] Failed to load', { error: err })
|
||||
}
|
||||
},
|
||||
|
||||
addAutoAllowedTool: async (toolId: string) => {
|
||||
try {
|
||||
logger.debug('[AutoAllowedTools] Adding tool...', { toolId })
|
||||
const res = await fetch(COPILOT_AUTO_ALLOWED_TOOLS_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolId }),
|
||||
})
|
||||
logger.debug('[AutoAllowedTools] API response', { toolId, status: res.status, ok: res.ok })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
logger.debug('[AutoAllowedTools] API returned', { toolId, tools: data.autoAllowedTools })
|
||||
const tools = data.autoAllowedTools ?? []
|
||||
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
|
||||
writeAutoAllowedToolsToStorage(tools)
|
||||
logger.debug('[AutoAllowedTools] Added tool to store', { toolId })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[AutoAllowedTools] Failed to add tool', { toolId, error: err })
|
||||
}
|
||||
},
|
||||
|
||||
removeAutoAllowedTool: async (toolId: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${COPILOT_AUTO_ALLOWED_TOOLS_API_PATH}?toolId=${encodeURIComponent(toolId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const tools = data.autoAllowedTools ?? []
|
||||
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
|
||||
writeAutoAllowedToolsToStorage(tools)
|
||||
logger.debug('[AutoAllowedTools] Removed tool', { toolId })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[AutoAllowedTools] Failed to remove tool', { toolId, error: err })
|
||||
}
|
||||
},
|
||||
|
||||
isToolAutoAllowed: (toolId: string) => {
|
||||
const { autoAllowedTools } = get()
|
||||
return isToolAutoAllowedByList(toolId, autoAllowedTools)
|
||||
},
|
||||
|
||||
// Credential masking
|
||||
loadSensitiveCredentialIds: async () => {
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,26 @@ export interface CopilotToolCall {
|
||||
params?: Record<string, unknown>
|
||||
input?: Record<string, unknown>
|
||||
display?: ClientToolDisplay
|
||||
/** Server-provided UI contract for this tool call phase */
|
||||
ui?: {
|
||||
title?: string
|
||||
phaseLabel?: string
|
||||
icon?: string
|
||||
showInterrupt?: boolean
|
||||
showRemember?: boolean
|
||||
autoAllowed?: boolean
|
||||
actions?: Array<{
|
||||
id: string
|
||||
label: string
|
||||
kind: 'accept' | 'reject'
|
||||
remember?: boolean
|
||||
}>
|
||||
}
|
||||
/** Server-provided execution routing contract */
|
||||
execution?: {
|
||||
target?: 'go' | 'go_subagent' | 'sim_server' | 'sim_client_capability' | string
|
||||
capabilityId?: string
|
||||
}
|
||||
/** Content streamed from a subagent (e.g., debug agent) */
|
||||
subAgentContent?: string
|
||||
/** Tool calls made by the subagent */
|
||||
@@ -167,10 +187,6 @@ export interface CopilotState {
|
||||
|
||||
// Per-message metadata captured at send-time for reliable stats
|
||||
|
||||
// Auto-allowed integration tools (tools that can run without confirmation)
|
||||
autoAllowedTools: string[]
|
||||
autoAllowedToolsLoaded: boolean
|
||||
|
||||
// Active stream metadata for reconnect/replay
|
||||
activeStream: CopilotStreamInfo | null
|
||||
|
||||
@@ -247,11 +263,6 @@ export interface CopilotActions {
|
||||
abortSignal?: AbortSignal
|
||||
) => Promise<void>
|
||||
handleNewChatCreation: (newChatId: string) => Promise<void>
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
addAutoAllowedTool: (toolId: string) => Promise<void>
|
||||
removeAutoAllowedTool: (toolId: string) => Promise<void>
|
||||
isToolAutoAllowed: (toolId: string) => boolean
|
||||
|
||||
// Credential masking
|
||||
loadSensitiveCredentialIds: () => Promise<void>
|
||||
maskCredentialValue: (value: string) => string
|
||||
|
||||
Reference in New Issue
Block a user