Files
sim/apps/sim/lib/copilot/store-utils.ts
Siddharth Ganesan 190f12fd77 feat(copilot): copilot mcp + server side copilot execution (#3173)
* v0

* v1

* Basic ss tes

* Ss tests

* Stuff

* Add mcp

* mcp v1

* Improvement

* Fix

* BROKEN

* Checkpoint

* Streaming

* Fix abort

* Things are broken

* Streaming seems to work but copilot is dumb

* Fix edge issue

* LUAAAA

* Fix stream buffer

* Fix lint

* Checkpoint

* Initial temp state, in the middle of a refactor

* Initial test shows diff store still working

* Tool refactor

* First cleanup pass complete - untested

* Continued cleanup

* Refactor

* Refactor complete - no testing yet

* Fix - cursor makes me sad

* Fix mcp

* Clean up mcp

* Updated mcp

* Add respond to subagents

* Fix definitions

* Add tools

* Add tools

* Add copilot mcp tracking

* Fix lint

* Fix mcp

* Fix

* Updates

* Clean up mcp

* Fix copilot mcp tool names to be sim prefixed

* Add opus 4.6

* Fix discovery tool

* Fix

* Remove logs

* Fix go side tool rendering

* Update docs

* Fix hydration

* Fix tool call resolution

* Fix

* Fix lint

* Fix superagent and autoallow integrations

* Fix always allow

* Update block

* Remove plan docs

* Fix hardcoded ff

* Fix dropped provider

* Fix lint

* Fix tests

* Fix dead messages array

* Fix discovery

* Fix run workflow

* Fix run block

* Fix run from block in copilot

* Fix lint

* Fix skip and mtb

* Fix typing

* Fix tool call

* Bump api version

* Fix bun lock

* Nuke bad files
2026-02-09 19:33:29 -08:00

202 lines
6.5 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { Loader2 } from 'lucide-react'
import {
ClientToolCallState,
type ClientToolDisplay,
TOOL_DISPLAY_REGISTRY,
} from '@/lib/copilot/tools/client/tool-display-registry'
import type { CopilotStore } from '@/stores/panel/copilot/types'
const logger = createLogger('CopilotStoreUtils')
type StoreSet = (
partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)
) => void
/** Respond tools are internal to copilot subagents and should never be shown in the UI */
const HIDDEN_TOOL_SUFFIX = '_respond'
export function resolveToolDisplay(
toolName: string | undefined,
state: ClientToolCallState,
_toolCallId?: string,
params?: Record<string, any>
): ClientToolDisplay | undefined {
if (!toolName) return undefined
if (toolName.endsWith(HIDDEN_TOOL_SUFFIX)) return undefined
const entry = TOOL_DISPLAY_REGISTRY[toolName]
if (!entry) return humanizedFallback(toolName, state)
if (entry.uiConfig?.dynamicText && params) {
const dynamicText = entry.uiConfig.dynamicText(params, state)
const stateDisplay = entry.displayNames[state]
if (dynamicText && stateDisplay?.icon) {
return { text: dynamicText, icon: stateDisplay.icon }
}
}
const display = entry.displayNames[state]
if (display?.text || display?.icon) return display
const fallbackOrder = [
ClientToolCallState.generating,
ClientToolCallState.executing,
ClientToolCallState.success,
]
for (const fallbackState of fallbackOrder) {
const fallback = entry.displayNames[fallbackState]
if (fallback?.text || fallback?.icon) return fallback
}
return humanizedFallback(toolName, state)
}
export function humanizedFallback(
toolName: string,
state: ClientToolCallState
): ClientToolDisplay | undefined {
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const stateVerb =
state === ClientToolCallState.success
? 'Executed'
: state === ClientToolCallState.error
? 'Failed'
: state === ClientToolCallState.rejected || state === ClientToolCallState.aborted
? 'Skipped'
: 'Executing'
return { text: `${stateVerb} ${formattedName}`, icon: Loader2 }
}
export function isRejectedState(state: string): boolean {
return state === 'rejected'
}
export function isReviewState(state: string): boolean {
return state === 'review'
}
export function isBackgroundState(state: string): boolean {
return state === 'background'
}
export function isTerminalState(state: string): boolean {
return (
state === ClientToolCallState.success ||
state === ClientToolCallState.error ||
state === ClientToolCallState.rejected ||
state === ClientToolCallState.aborted ||
isReviewState(state) ||
isBackgroundState(state)
)
}
/**
* Resolves the appropriate terminal state for a non-terminal tool call.
* 'executing' → 'success': the server was running it, assume it completed.
* Everything else → 'aborted': never reached execution.
*/
function resolveAbortState(currentState: string): ClientToolCallState {
return currentState === ClientToolCallState.executing
? ClientToolCallState.success
: ClientToolCallState.aborted
}
export function abortAllInProgressTools(set: StoreSet, get: () => CopilotStore) {
try {
const { toolCallsById, messages } = get()
const updatedMap = { ...toolCallsById }
const resolvedIds = new Map<string, ClientToolCallState>()
let hasUpdates = false
for (const [id, tc] of Object.entries(toolCallsById)) {
const st = tc.state
const isTerminal =
st === ClientToolCallState.success ||
st === ClientToolCallState.error ||
st === ClientToolCallState.rejected ||
st === ClientToolCallState.aborted
if (!isTerminal || isReviewState(st)) {
const resolved = resolveAbortState(st)
resolvedIds.set(id, resolved)
updatedMap[id] = {
...tc,
state: resolved,
subAgentStreaming: false,
display: resolveToolDisplay(tc.name, resolved, id, tc.params),
}
hasUpdates = true
} else if (tc.subAgentStreaming) {
updatedMap[id] = {
...tc,
subAgentStreaming: false,
}
hasUpdates = true
}
}
if (resolvedIds.size > 0 || hasUpdates) {
set({ toolCallsById: updatedMap })
set((s: CopilotStore) => {
const msgs = [...s.messages]
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const m = msgs[mi]
if (m.role !== 'assistant' || !Array.isArray(m.contentBlocks)) continue
let changed = false
const blocks = m.contentBlocks.map((b: any) => {
if (b?.type === 'tool_call' && b.toolCall?.id && resolvedIds.has(b.toolCall.id)) {
changed = true
const prev = b.toolCall
const resolved = resolvedIds.get(b.toolCall.id)!
return {
...b,
toolCall: {
...prev,
state: resolved,
display: resolveToolDisplay(prev?.name, resolved, prev?.id, prev?.params),
},
}
}
return b
})
if (changed) {
msgs[mi] = { ...m, contentBlocks: blocks }
break
}
}
return { messages: msgs }
})
}
} catch (error) {
logger.warn('Failed to abort in-progress tools', {
error: error instanceof Error ? error.message : String(error),
})
}
}
export function cleanupActiveState(
set: (partial: Record<string, unknown>) => void,
get: () => Record<string, unknown>
): void {
abortAllInProgressTools(set as unknown as StoreSet, get as unknown as () => CopilotStore)
try {
const { useWorkflowDiffStore } = require('@/stores/workflow-diff/store') as {
useWorkflowDiffStore: {
getState: () => { clearDiff: (options?: { restoreBaseline?: boolean }) => void }
}
}
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch (error) {
logger.warn('Failed to clear diff during cleanup', {
error: error instanceof Error ? error.message : String(error),
})
}
}
export function stripTodoTags(text: string): string {
if (!text) return text
return text
.replace(/<marktodo>[\s\S]*?<\/marktodo>/g, '')
.replace(/<checkofftodo>[\s\S]*?<\/checkofftodo>/g, '')
.replace(/<design_workflow>[\s\S]*?<\/design_workflow>/g, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{2,}/g, '\n')
}