mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Checkpoint
This commit is contained in:
@@ -6,10 +6,7 @@ import clsx from 'clsx'
|
||||
import { ChevronUp, LayoutList } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
|
||||
import {
|
||||
CLIENT_EXECUTABLE_RUN_TOOLS,
|
||||
executeRunToolOnClient,
|
||||
} from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import { executeRunToolOnClient } from '@/lib/copilot/client-sse/run-tool-execution'
|
||||
import {
|
||||
ClientToolCallState,
|
||||
TOOL_DISPLAY_REGISTRY,
|
||||
@@ -1219,36 +1216,17 @@ 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Show approval buttons when the tool is in pending state.
|
||||
* The Go backend already decided whether confirmation is needed via
|
||||
* `requiresConfirmation` — if set, the SSE handler puts the tool in
|
||||
* `pending`; otherwise it goes straight to `executing`.
|
||||
*/
|
||||
function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
|
||||
if (!toolCall.name || toolCall.name === 'unknown_tool') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (toolCall.state !== ClientToolCallState.pending) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Never show buttons for tools the user has marked as always-allowed
|
||||
if (useCopilotStore.getState().isToolAutoAllowed(toolCall.name)) {
|
||||
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 toolCall.state === ClientToolCallState.pending
|
||||
}
|
||||
|
||||
const toolCallLogger = createLogger('CopilotToolCall')
|
||||
@@ -1282,10 +1260,7 @@ async function handleRun(
|
||||
onStateChange?.('executing')
|
||||
await sendToolDecision(toolCall.id, 'accepted')
|
||||
|
||||
// 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 (toolCall.clientExecutable) {
|
||||
const params = editedParams || toolCall.params || {}
|
||||
executeRunToolOnClient(toolCall.id, toolCall.name, params)
|
||||
}
|
||||
@@ -1449,9 +1424,7 @@ export function ToolCall({
|
||||
|
||||
// Check if this integration tool is auto-allowed
|
||||
const { removeAutoAllowedTool, setToolCallState } = useCopilotStore()
|
||||
const isAutoAllowed = useCopilotStore(
|
||||
(s) => isIntegrationTool(toolCall.name) && s.isToolAutoAllowed(toolCall.name)
|
||||
)
|
||||
const isAutoAllowed = useCopilotStore((s) => s.isToolAutoAllowed(toolCall.name))
|
||||
|
||||
// Update edited params when toolCall params change (deep comparison to avoid resetting user edits on ref change)
|
||||
useEffect(() => {
|
||||
@@ -1516,9 +1489,9 @@ export function ToolCall({
|
||||
// 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
|
||||
const isServerToolInBuildMode = mode === 'build' && !isClientTool
|
||||
|
||||
if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) {
|
||||
if (!isClientTool && !isServerToolInBuildMode && !isCompletedToolCall) {
|
||||
return null
|
||||
}
|
||||
const toolUIConfig = TOOL_DISPLAY_REGISTRY[toolCall.name]?.uiConfig
|
||||
|
||||
@@ -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 {
|
||||
@@ -16,7 +16,7 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { appendTextBlock, beginThinkingBlock, finalizeThinkingBlock } from './content-blocks'
|
||||
import { CLIENT_EXECUTABLE_RUN_TOOLS, executeRunToolOnClient } from './run-tool-execution'
|
||||
import { executeRunToolOnClient } from './run-tool-execution'
|
||||
import type { ClientContentBlock, ClientStreamingContext } from './types'
|
||||
|
||||
const logger = createLogger('CopilotClientSseHandlers')
|
||||
@@ -26,23 +26,6 @@ const MAX_BATCH_INTERVAL = 50
|
||||
const MIN_BATCH_INTERVAL = 16
|
||||
const MAX_QUEUE_SIZE = 5
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -245,10 +228,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const eventData = asRecord(data?.data)
|
||||
const toolCallId: string | undefined =
|
||||
data?.toolCallId || (eventData.id 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 { toolCallsById } = get()
|
||||
const current = toolCallsById[toolCallId]
|
||||
@@ -260,11 +239,9 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const targetState = success
|
||||
? ClientToolCallState.success
|
||||
: failedDependency || skipped
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const targetState =
|
||||
(data?.state as ClientToolCallState) ||
|
||||
(data?.success ? ClientToolCallState.success : ClientToolCallState.error)
|
||||
const updatedMap = { ...toolCallsById }
|
||||
updatedMap[toolCallId] = {
|
||||
...current,
|
||||
@@ -469,6 +446,9 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
const blockState =
|
||||
(data?.state as ClientToolCallState) ||
|
||||
(data?.success ? ClientToolCallState.success : ClientToolCallState.error)
|
||||
for (let i = 0; i < context.contentBlocks.length; i++) {
|
||||
const b = context.contentBlocks[i]
|
||||
if (b?.type === 'tool_call' && b?.toolCall?.id === toolCallId) {
|
||||
@@ -478,19 +458,14 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
isBackgroundState(b.toolCall?.state)
|
||||
)
|
||||
break
|
||||
const targetState = success
|
||||
? ClientToolCallState.success
|
||||
: failedDependency || skipped
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
context.contentBlocks[i] = {
|
||||
...b,
|
||||
toolCall: {
|
||||
...b.toolCall,
|
||||
state: targetState,
|
||||
state: blockState,
|
||||
display: resolveToolDisplay(
|
||||
b.toolCall?.name,
|
||||
targetState,
|
||||
blockState,
|
||||
toolCallId,
|
||||
b.toolCall?.params,
|
||||
b.toolCall?.serverUI
|
||||
@@ -512,8 +487,8 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const errorData = asRecord(data?.data)
|
||||
const toolCallId: string | undefined =
|
||||
data?.toolCallId || (errorData.id as string | undefined)
|
||||
const failedDependency: boolean = data?.failedDependency === true
|
||||
if (!toolCallId) return
|
||||
const targetState = (data?.state as ClientToolCallState) || ClientToolCallState.error
|
||||
const { toolCallsById } = get()
|
||||
const current = toolCallsById[toolCallId]
|
||||
if (current) {
|
||||
@@ -524,9 +499,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const targetState = failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
const updatedMap = { ...toolCallsById }
|
||||
updatedMap[toolCallId] = {
|
||||
...current,
|
||||
@@ -544,9 +516,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
isBackgroundState(b.toolCall?.state)
|
||||
)
|
||||
break
|
||||
const targetState = failedDependency
|
||||
? ClientToolCallState.rejected
|
||||
: ClientToolCallState.error
|
||||
context.contentBlocks[i] = {
|
||||
...b,
|
||||
toolCall: {
|
||||
@@ -577,15 +546,11 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const { toolCallsById } = get()
|
||||
|
||||
if (!toolCallsById[toolCallId]) {
|
||||
const isAutoAllowed = get().isToolAutoAllowed(toolName)
|
||||
const initialState = isAutoAllowed
|
||||
? ClientToolCallState.executing
|
||||
: ClientToolCallState.pending
|
||||
const tc: CopilotToolCall = {
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
state: initialState,
|
||||
display: resolveToolDisplay(toolName, initialState, toolCallId),
|
||||
state: ClientToolCallState.generating,
|
||||
display: resolveToolDisplay(toolName, ClientToolCallState.generating, toolCallId),
|
||||
}
|
||||
const updated = { ...toolCallsById, [toolCallId]: tc }
|
||||
set({ toolCallsById: updated })
|
||||
@@ -604,7 +569,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const isPartial = toolData.partial === true
|
||||
const { toolCallsById } = get()
|
||||
|
||||
// Extract copilot-provided UI metadata for fallback display
|
||||
const rawUI = (toolData.ui || data?.ui) as Record<string, unknown> | undefined
|
||||
const serverUI = rawUI
|
||||
? {
|
||||
@@ -616,10 +580,16 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
const existing = toolCallsById[id]
|
||||
const toolName = name || existing?.name || 'unknown_tool'
|
||||
const isAutoAllowed = get().isToolAutoAllowed(toolName)
|
||||
let initialState = isAutoAllowed ? ClientToolCallState.executing : ClientToolCallState.pending
|
||||
|
||||
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
|
||||
const clientExecutable = rawUI?.clientExecutable === true
|
||||
|
||||
let initialState: ClientToolCallState
|
||||
if (isPartial) {
|
||||
initialState = existing?.state || ClientToolCallState.generating
|
||||
} else {
|
||||
initialState = (data?.state as ClientToolCallState) || ClientToolCallState.executing
|
||||
}
|
||||
|
||||
if (
|
||||
existing?.state === ClientToolCallState.executing &&
|
||||
initialState === ClientToolCallState.pending
|
||||
@@ -636,6 +606,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
state: initialState,
|
||||
...(args ? { params: args } : {}),
|
||||
...(effectiveServerUI ? { serverUI: effectiveServerUI } : {}),
|
||||
...(clientExecutable ? { clientExecutable: true } : {}),
|
||||
display: resolveToolDisplay(toolName, initialState, id, args || existing.params, effectiveServerUI),
|
||||
}
|
||||
: {
|
||||
@@ -644,6 +615,7 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
state: initialState,
|
||||
...(args ? { params: args } : {}),
|
||||
...(serverUI ? { serverUI } : {}),
|
||||
...(clientExecutable ? { clientExecutable: true } : {}),
|
||||
display: resolveToolDisplay(toolName, initialState, id, args, serverUI),
|
||||
}
|
||||
const updated = { ...toolCallsById, [id]: next }
|
||||
@@ -657,23 +629,10 @@ 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
) {
|
||||
if (clientExecutable && initialState === ClientToolCallState.executing) {
|
||||
executeRunToolOnClient(id, toolName, args || existing?.params || {})
|
||||
}
|
||||
|
||||
// OAuth: dispatch event to open the OAuth connect modal
|
||||
if (toolName === 'oauth_request_access' && args && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
|
||||
@@ -10,17 +10,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('CopilotRunToolExecution')
|
||||
|
||||
/**
|
||||
* Run tools that execute client-side for real-time feedback
|
||||
* (block pulsing, logs, stop button, etc.).
|
||||
*/
|
||||
export const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
|
||||
'run_workflow',
|
||||
'run_workflow_until_block',
|
||||
'run_from_block',
|
||||
'run_block',
|
||||
])
|
||||
|
||||
/**
|
||||
* Execute a run tool on the client side using the streaming execute endpoint.
|
||||
* This gives full interactive feedback: block pulsing, console logs, stop button.
|
||||
|
||||
@@ -11,11 +11,10 @@ import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-reg
|
||||
import type { CopilotStore, CopilotToolCall } from '@/stores/panel/copilot/types'
|
||||
import {
|
||||
type SSEHandler,
|
||||
sendAutoAcceptConfirmation,
|
||||
sseHandlers,
|
||||
updateStreamingMessage,
|
||||
} from './handlers'
|
||||
import { CLIENT_EXECUTABLE_RUN_TOOLS, executeRunToolOnClient } from './run-tool-execution'
|
||||
import { executeRunToolOnClient } from './run-tool-execution'
|
||||
import type { ClientStreamingContext } from './types'
|
||||
|
||||
const logger = createLogger('CopilotClientSubagentHandlers')
|
||||
@@ -199,11 +198,16 @@ 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 rawUI = (toolData.ui || data?.ui) as Record<string, unknown> | undefined
|
||||
const clientExecutable = rawUI?.clientExecutable === true
|
||||
|
||||
let initialState: ClientToolCallState
|
||||
if (isPartial) {
|
||||
initialState = existingToolCall?.state || ClientToolCallState.generating
|
||||
} else {
|
||||
initialState = (data?.state as ClientToolCallState) || ClientToolCallState.executing
|
||||
}
|
||||
|
||||
// Avoid flickering back to pending on partial/duplicate events once a tool is executing.
|
||||
if (
|
||||
existingToolCall?.state === ClientToolCallState.executing &&
|
||||
initialState === ClientToolCallState.pending
|
||||
@@ -216,6 +220,7 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
name,
|
||||
state: initialState,
|
||||
...(args ? { params: args } : {}),
|
||||
...(clientExecutable ? { clientExecutable: true } : {}),
|
||||
display: resolveToolDisplay(name, initialState, id, args),
|
||||
}
|
||||
|
||||
@@ -241,16 +246,7 @@ 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (clientExecutable && initialState === ClientToolCallState.executing) {
|
||||
executeRunToolOnClient(id, name, args || {})
|
||||
}
|
||||
},
|
||||
@@ -261,21 +257,14 @@ export const subAgentSSEHandlers: Record<string, SSEHandler> = {
|
||||
|
||||
const resultData = asRecord(data?.data)
|
||||
const toolCallId: string | undefined = data?.toolCallId || (resultData.id as string | undefined)
|
||||
// Determine success: explicit `success` field takes priority; otherwise
|
||||
// infer from presence of result data vs error (same logic as server-side
|
||||
// inferToolSuccess). The Go backend uses `*bool` with omitempty so
|
||||
// `success` is present when explicitly set, and absent for non-tool events.
|
||||
const hasExplicitSuccess = data?.success !== undefined || resultData.success !== undefined
|
||||
const explicitSuccess = data?.success ?? resultData.success
|
||||
const hasResultData = data?.result !== undefined || resultData.result !== undefined
|
||||
const hasError = !!data?.error || !!resultData.error
|
||||
const success: boolean = hasExplicitSuccess ? !!explicitSuccess : hasResultData && !hasError
|
||||
if (!toolCallId) return
|
||||
|
||||
if (!context.subAgentToolCalls[parentToolCallId]) return
|
||||
if (!context.subAgentBlocks[parentToolCallId]) return
|
||||
|
||||
const targetState = success ? ClientToolCallState.success : ClientToolCallState.error
|
||||
const targetState =
|
||||
(data?.state as ClientToolCallState) ||
|
||||
(data?.success ? ClientToolCallState.success : ClientToolCallState.error)
|
||||
const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
|
||||
(tc: CopilotToolCall) => tc.id === toolCallId
|
||||
)
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
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',
|
||||
'update_workspace_mcp_server',
|
||||
'delete_workspace_mcp_server',
|
||||
'delete_workflow',
|
||||
'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',
|
||||
|
||||
@@ -7,18 +7,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
const { executeToolServerSide, markToolComplete, isIntegrationTool, isToolAvailableOnSimSide } =
|
||||
const { executeToolServerSide, markToolComplete, isToolAvailableOnSimSide } =
|
||||
vi.hoisted(() => ({
|
||||
executeToolServerSide: vi.fn(),
|
||||
markToolComplete: vi.fn(),
|
||||
isIntegrationTool: vi.fn().mockReturnValue(false),
|
||||
isToolAvailableOnSimSide: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/orchestrator/tool-executor', () => ({
|
||||
executeToolServerSide,
|
||||
markToolComplete,
|
||||
isIntegrationTool,
|
||||
isToolAvailableOnSimSide,
|
||||
}))
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
wasToolResultSeen,
|
||||
} from '@/lib/copilot/orchestrator/sse-utils'
|
||||
import {
|
||||
isIntegrationTool,
|
||||
isToolAvailableOnSimSide,
|
||||
markToolComplete,
|
||||
} from '@/lib/copilot/orchestrator/tool-executor'
|
||||
@@ -22,7 +21,6 @@ import type {
|
||||
} from '@/lib/copilot/orchestrator/types'
|
||||
import {
|
||||
executeToolAndReport,
|
||||
isInterruptToolName,
|
||||
waitForToolCompletion,
|
||||
waitForToolDecision,
|
||||
} from './tool-execution'
|
||||
@@ -30,16 +28,74 @@ import {
|
||||
const logger = createLogger('CopilotSseHandlers')
|
||||
|
||||
/**
|
||||
* Run tools that can be executed client-side for real-time feedback
|
||||
* (block pulsing, logs, stop button). When interactive, the server defers
|
||||
* execution to the browser client instead of running executeWorkflow directly.
|
||||
* Extract the `ui` object from a Go SSE event. The Go backend enriches
|
||||
* tool_call events with `ui: { requiresConfirmation, clientExecutable, ... }`.
|
||||
*/
|
||||
const CLIENT_EXECUTABLE_RUN_TOOLS = new Set([
|
||||
'run_workflow',
|
||||
'run_workflow_until_block',
|
||||
'run_from_block',
|
||||
'run_block',
|
||||
])
|
||||
function getEventUI(event: SSEEvent): { requiresConfirmation: boolean; clientExecutable: boolean } {
|
||||
const raw = asRecord((event as unknown as Record<string, unknown>).ui)
|
||||
return {
|
||||
requiresConfirmation: raw.requiresConfirmation === true,
|
||||
clientExecutable: raw.clientExecutable === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the completion signal from a client-executable tool.
|
||||
* Shared by both the main and subagent tool_call handlers.
|
||||
*/
|
||||
function handleClientCompletion(
|
||||
toolCall: ToolCallState,
|
||||
toolCallId: string,
|
||||
completion: { status: string; message?: string } | null
|
||||
): void {
|
||||
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 (client background)', {
|
||||
toolCallId: toolCall.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
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 (client rejected)', {
|
||||
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 (client completion)', {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
markToolResultSeen(toolCallId)
|
||||
}
|
||||
|
||||
// Normalization + dedupe helpers live in sse-utils to keep server/client in sync.
|
||||
|
||||
@@ -138,8 +194,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
const isPartial = toolData.partial === true
|
||||
const existing = context.toolCalls.get(toolCallId)
|
||||
|
||||
// If we've already completed this tool call, ignore late/duplicate tool_call events
|
||||
// to avoid resetting UI/state back to pending and re-executing.
|
||||
if (
|
||||
existing?.endTime ||
|
||||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
|
||||
@@ -170,13 +224,10 @@ 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()
|
||||
@@ -187,63 +238,27 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
const isInterruptTool = isInterruptToolName(toolName)
|
||||
const isInteractive = options.interactive === true
|
||||
// Integration tools (user-installed) also require approval in interactive mode
|
||||
const needsApproval = isInterruptTool || isIntegrationTool(toolName)
|
||||
// Go backend decides whether a tool needs confirmation via `ui.requiresConfirmation`.
|
||||
// If the flag is set, wait for client approval before executing.
|
||||
// If `ui.clientExecutable` is set, the client runs the tool and reports back.
|
||||
const { requiresConfirmation, clientExecutable } = getEventUI(event)
|
||||
|
||||
if (needsApproval && isInteractive) {
|
||||
if (requiresConfirmation) {
|
||||
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)) {
|
||||
if (clientExecutable) {
|
||||
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)
|
||||
handleClientCompletion(toolCall, toolCallId, completion)
|
||||
return
|
||||
}
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
@@ -253,7 +268,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
if (decision?.status === 'rejected' || decision?.status === 'error') {
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
// Fire-and-forget: must NOT await — see deadlock note in executeToolAndReport
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
@@ -273,7 +287,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
if (decision?.status === 'background') {
|
||||
toolCall.status = 'skipped'
|
||||
toolCall.endTime = Date.now()
|
||||
// Fire-and-forget: must NOT await — see deadlock note in executeToolAndReport
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
@@ -290,9 +303,6 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Decision was null — timed out or aborted.
|
||||
// Do NOT fall through to auto-execute. Mark the tool as timed out
|
||||
// and notify Go so it can unblock waitForExternalTool.
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(toolCall.id, toolCall.name, 408, 'Tool approval timed out', {
|
||||
@@ -308,6 +318,18 @@ export const sseHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-allowed client-executable tool: client runs it, we wait for completion.
|
||||
if (clientExecutable) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
handleClientCompletion(toolCall, toolCallId, completion)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.autoExecuteTools !== false) {
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
}
|
||||
@@ -439,32 +461,35 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const { requiresConfirmation, clientExecutable } = getEventUI(event)
|
||||
|
||||
if (requiresConfirmation) {
|
||||
const decision = await waitForToolDecision(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
if (decision?.status === 'accepted' || decision?.status === 'success') {
|
||||
if (clientExecutable) {
|
||||
toolCall.status = 'executing'
|
||||
const completion = await waitForToolCompletion(
|
||||
toolCallId,
|
||||
options.timeout || STREAM_TIMEOUT_MS,
|
||||
options.abortSignal
|
||||
)
|
||||
handleClientCompletion(toolCall, toolCallId, completion)
|
||||
return
|
||||
}
|
||||
await executeToolAndReport(toolCallId, context, execContext, options)
|
||||
return
|
||||
}
|
||||
if (decision?.status === 'rejected' || decision?.status === 'error') {
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
// Fire-and-forget: must NOT await — see deadlock note in executeToolAndReport
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
@@ -483,7 +508,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
if (decision?.status === 'background') {
|
||||
toolCall.status = 'skipped'
|
||||
toolCall.endTime = Date.now()
|
||||
// Fire-and-forget: must NOT await — see deadlock note in executeToolAndReport
|
||||
markToolComplete(
|
||||
toolCall.id,
|
||||
toolCall.name,
|
||||
@@ -500,8 +524,6 @@ export const subAgentHandlers: Record<string, SSEHandler> = {
|
||||
return
|
||||
}
|
||||
|
||||
// Decision was null — timed out or aborted.
|
||||
// Do NOT fall through to auto-execute.
|
||||
toolCall.status = 'rejected'
|
||||
toolCall.endTime = Date.now()
|
||||
markToolComplete(toolCall.id, toolCall.name, 408, 'Tool approval timed out', {
|
||||
@@ -517,62 +539,14 @@ 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)) {
|
||||
if (clientExecutable) {
|
||||
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)
|
||||
handleClientCompletion(toolCall, toolCallId, completion)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -142,6 +137,7 @@ export async function executeToolAndReport(
|
||||
|
||||
const errorEvent: SSEEvent = {
|
||||
type: 'tool_error',
|
||||
state: 'error',
|
||||
toolCallId: toolCall.id,
|
||||
data: {
|
||||
id: toolCall.id,
|
||||
|
||||
@@ -451,19 +451,6 @@ export function isToolAvailableOnSimSide(toolName: string): boolean {
|
||||
return !!getTool(resolvedToolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a tool is a user-installed integration tool (e.g. Gmail, Slack).
|
||||
* These tools exist in the tool registry but are NOT copilot server tools or
|
||||
* known workflow manipulation tools. They should require user approval in
|
||||
* interactive mode.
|
||||
*/
|
||||
export function isIntegrationTool(toolName: string): boolean {
|
||||
if (SERVER_TOOLS.has(toolName)) return false
|
||||
if (toolName in SIM_WORKFLOW_TOOL_HANDLERS) return false
|
||||
const resolvedToolName = resolveToolId(toolName)
|
||||
return !!getTool(resolvedToolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool server-side without calling internal routes.
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,8 @@ export type SSEEventType =
|
||||
|
||||
export interface SSEEvent {
|
||||
type: SSEEventType
|
||||
/** Authoritative tool call state set by the Go backend */
|
||||
state?: string
|
||||
data?: Record<string, unknown>
|
||||
subagent?: string
|
||||
toolCallId?: string
|
||||
@@ -33,8 +35,6 @@ export interface SSEEvent {
|
||||
content?: string
|
||||
/** Set on reasoning events */
|
||||
phase?: string
|
||||
/** Set on tool_result events */
|
||||
failedDependency?: boolean
|
||||
/** UI metadata from copilot (title, icon, phaseLabel) */
|
||||
ui?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -140,34 +140,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
|
||||
@@ -1037,8 +1009,6 @@ async function resumeFromLiveStream(
|
||||
return false
|
||||
}
|
||||
|
||||
const cachedAutoAllowedTools = readAutoAllowedToolsFromStorage()
|
||||
|
||||
// Initial state (subset required for UI/streaming)
|
||||
const initialState = {
|
||||
mode: 'build' as const,
|
||||
@@ -1073,8 +1043,8 @@ const initialState = {
|
||||
streamingPlanContent: '',
|
||||
toolCallsById: {} as Record<string, CopilotToolCall>,
|
||||
suppressAutoSelect: false,
|
||||
autoAllowedTools: cachedAutoAllowedTools ?? ([] as string[]),
|
||||
autoAllowedToolsLoaded: cachedAutoAllowedTools !== null,
|
||||
autoAllowedTools: [] as string[],
|
||||
autoAllowedToolsLoaded: false,
|
||||
activeStream: null as CopilotStreamInfo | null,
|
||||
messageQueue: [] as import('./types').QueuedMessage[],
|
||||
suppressAbortContinueOption: false,
|
||||
@@ -2416,7 +2386,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
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 })
|
||||
@@ -2442,7 +2411,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
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) {
|
||||
@@ -2462,7 +2430,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
const data = await res.json()
|
||||
const tools = data.autoAllowedTools ?? []
|
||||
set({ autoAllowedTools: tools, autoAllowedToolsLoaded: true })
|
||||
writeAutoAllowedToolsToStorage(tools)
|
||||
logger.debug('[AutoAllowedTools] Removed tool', { toolId })
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface CopilotToolCall {
|
||||
display?: ClientToolDisplay
|
||||
/** UI metadata from the copilot SSE event (used as fallback for unregistered tools) */
|
||||
serverUI?: ServerToolUI
|
||||
/** Tool should be executed client-side (set by Go backend via SSE) */
|
||||
clientExecutable?: boolean
|
||||
/** Content streamed from a subagent (e.g., debug agent) */
|
||||
subAgentContent?: string
|
||||
/** Tool calls made by the subagent */
|
||||
|
||||
Reference in New Issue
Block a user