Checkpoint

This commit is contained in:
Siddharth Ganesan
2026-02-24 13:47:29 -08:00
parent 134c4c4f2a
commit d333307a17
12 changed files with 162 additions and 353 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,
@@ -142,6 +137,7 @@ export async function executeToolAndReport(
const errorEvent: SSEEvent = {
type: 'tool_error',
state: 'error',
toolCallId: toolCall.id,
data: {
id: toolCall.id,

View File

@@ -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.
*/

View File

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

View File

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

View File

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