SSE interface

This commit is contained in:
Siddharth Ganesan
2026-02-11 18:22:26 -08:00
parent 462aa15341
commit c22bd2caaa
13 changed files with 693 additions and 706 deletions

View File

@@ -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 }
)
}
}

View File

@@ -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()

View File

@@ -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]'>

View File

@@ -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,
})

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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),
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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