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