diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 356ea7e00ad..a8a0ae35b9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,14 +1,14 @@ 'use client' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { stripVersionSuffix } from '@sim/utils/string' import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' +import { getToolDisplayTitle, humanizeToolName } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import type { ContentBlock, OptionItem, ToolCallData } from '../../types' -import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' +import { SUBAGENT_LABELS } from '../../types' import type { AgentGroupItem } from './components' import { AgentGroup, @@ -84,16 +84,9 @@ function isHiddenToolCall(toolName: string | undefined): boolean { return isToolHiddenInUi(toolName) } -function formatToolName(name: string): string { - return stripVersionSuffix(name) - .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' ') -} - function resolveAgentLabel(key: string): string { if (key === 'mothership') return 'Sim' - return SUBAGENT_LABELS[key] ?? formatToolName(key) + return SUBAGENT_LABELS[key] ?? humanizeToolName(key) } function isDelegatingTool(tc: NonNullable): boolean { @@ -129,10 +122,7 @@ function getOverrideDisplayTitle(tc: NonNullable): str function toToolData(tc: NonNullable): ToolCallData { const overrideDisplayTitle = getOverrideDisplayTitle(tc) const displayTitle = - overrideDisplayTitle || - tc.displayTitle || - TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || - formatToolName(tc.name) + overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) return { id: tc.id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 9560560e1b9..72e1bd2dabc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -2,27 +2,25 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { CrawlWebsite, - CreateFolder, - DeleteFolder, DeleteWorkflow, DeployApi, DeployChat, DeployMcp, FunctionExecute, - GetPageContents, Glob, Grep, ManageCredential, ManageCredentialOperation, ManageCustomTool, ManageCustomToolOperation, + ManageFolder, + ManageFolderOperation, ManageMcpTool, ManageMcpToolOperation, ManageScheduledTask, ManageScheduledTaskOperation, ManageSkill, ManageSkillOperation, - MoveFolder, MoveWorkflow, QueryLogs, Redeploy, @@ -36,6 +34,7 @@ import { WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' @@ -53,11 +52,7 @@ export const DEPLOY_TOOL_NAMES: Set = new Set([ Redeploy.id, ]) -export const FOLDER_TOOL_NAMES: Set = new Set([ - CreateFolder.id, - DeleteFolder.id, - MoveFolder.id, -]) +export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id]) export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ MoveWorkflow.id, @@ -160,11 +155,6 @@ function stringParam(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } -function stringArrayParam(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) -} - function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined { const id = stringParam(workflowId) if (!id) return undefined @@ -218,125 +208,18 @@ function functionExecuteTitle(title: string | undefined): string { return title ?? 'Running code' } -export function resolveToolDisplayTitle( - name: string, - args?: Record -): string | undefined { - if (!args) return undefined - - if (name === FunctionExecute.id) { - return functionExecuteTitle(stringParam(args.title)) - } - - if (name === WorkspaceFile.id) { - const target = asPayloadRecord(args.target) - return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName) - } - - if (name === SearchOnline.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Searching online for ${toolTitle}` : 'Searching online' - } - - if (name === Grep.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Searching for ${toolTitle}` : 'Searching' - } - - if (name === Glob.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Finding ${toolTitle}` : 'Finding files' - } - - if (name === ScrapePage.id) { - const url = stringParam(args.url) - return url ? `Scraping ${url}` : 'Scraping page' - } - - if (name === CrawlWebsite.id) { - const url = stringParam(args.url) - return url ? `Crawling ${url}` : 'Crawling website' - } - - if (name === GetPageContents.id) { - const urls = stringArrayParam(args.urls) - if (urls.length === 1) return `Getting ${urls[0]}` - if (urls.length > 1) return `Getting ${urls.length} pages` - return 'Getting page contents' - } - - if (name === ManageCustomTool.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageCustomToolOperation.add]: 'Creating custom tool', - [ManageCustomToolOperation.edit]: 'Updating custom tool', - [ManageCustomToolOperation.delete]: 'Deleting custom tool', - [ManageCustomToolOperation.list]: 'Listing custom tools', - }, - 'Custom tool action' - ) - } - - if (name === ManageMcpTool.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageMcpToolOperation.add]: 'Creating MCP server', - [ManageMcpToolOperation.edit]: 'Updating MCP server', - [ManageMcpToolOperation.delete]: 'Deleting MCP server', - [ManageMcpToolOperation.list]: 'Listing MCP servers', - }, - 'MCP server action' - ) - } - - if (name === ManageSkill.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageSkillOperation.add]: 'Creating skill', - [ManageSkillOperation.edit]: 'Updating skill', - [ManageSkillOperation.delete]: 'Deleting skill', - [ManageSkillOperation.list]: 'Listing skills', - }, - 'Skill action' - ) - } - - if (name === ManageScheduledTask.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageScheduledTaskOperation.create]: 'Creating scheduled task', - [ManageScheduledTaskOperation.get]: 'Getting scheduled task', - [ManageScheduledTaskOperation.update]: 'Updating scheduled task', - [ManageScheduledTaskOperation.delete]: 'Deleting scheduled task', - [ManageScheduledTaskOperation.list]: 'Listing scheduled tasks', - }, - 'Scheduled task action' - ) - } - - if (name === ManageCredential.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageCredentialOperation.rename]: 'Renaming credential', - [ManageCredentialOperation.delete]: 'Deleting credential', - }, - 'Credential action' - ) - } - +export function resolveToolDisplayTitle(name: string, args?: Record): string { + // Cases that enrich the title with live workspace/block names from the client + // stores. Everything else is resolved by the shared name+args resolver, which + // is the single source of truth for tool-call titles. if (name === RunWorkflow.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) + const workflowName = resolveWorkflowNameForDisplay(args?.workflowId) return workflowName ? `Running ${workflowName}` : 'Running workflow' } if (name === RunFromBlock.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - const blockName = resolveBlockNameForDisplay(args.startBlockId) + const workflowName = resolveWorkflowNameForDisplay(args?.workflowId) + const blockName = resolveBlockNameForDisplay(args?.startBlockId) if (workflowName && blockName) return `Running ${workflowName} from ${blockName}` if (workflowName) return `Running ${workflowName}` if (blockName) return `Running from ${blockName}` @@ -344,8 +227,8 @@ export function resolveToolDisplayTitle( } if (name === RunWorkflowUntilBlock.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - const blockName = resolveBlockNameForDisplay(args.stopAfterBlockId) + const workflowName = resolveWorkflowNameForDisplay(args?.workflowId) + const blockName = resolveBlockNameForDisplay(args?.stopAfterBlockId) if (workflowName && blockName) return `Running ${workflowName} until ${blockName}` if (workflowName) return `Running ${workflowName}` if (blockName) return `Running until ${blockName}` @@ -354,11 +237,11 @@ export function resolveToolDisplayTitle( if (name === QueryLogs.id) { const workflowName = - resolveWorkflowNameForDisplay(args.workflowId) ?? stringParam(args.workflowName) - return workflowName ? `Querying logs for ${workflowName}` : undefined + resolveWorkflowNameForDisplay(args?.workflowId) ?? stringParam(args?.workflowName) + if (workflowName) return `Querying logs for ${workflowName}` } - return undefined + return getToolDisplayTitle(name, args) } function decodeStreamingString(value: string): string { @@ -480,5 +363,18 @@ export function resolveStreamingToolDisplayTitle( ) } + if (name === ManageFolder.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageFolderOperation.create]: 'Creating folder', + [ManageFolderOperation.rename]: 'Renaming folder', + [ManageFolderOperation.move]: 'Moving folder', + [ManageFolderOperation.delete]: 'Deleting folder', + }, + 'Folder action' + ) + } + return undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 3a2fd46e400..11acb17ec32 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -477,9 +477,9 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve tsMs ) if (isRecord(payload.arguments)) node.args = payload.arguments + // Tool-call titles are derived from the tool name (+args) at serialize + // time; the stream only carries behavioral flags now. const ui = isRecord(payload.ui) ? payload.ui : undefined - const uiTitle = ui ? (asString(ui.title) ?? asString(ui.phaseLabel)) : undefined - if (uiTitle) node.uiTitle = uiTitle if (ui?.hidden === true) node.hidden = true } else if (phase === MothershipStreamV1ToolPhase.args_delta) { const node = upsertToolNode( diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 531a5a18639..20bc2513dab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,36 +1,3 @@ -import { - Agent, - Auth, - CreateWorkflow, - Deploy, - EditWorkflow, - Ffmpeg, - FunctionExecute, - GenerateAudio, - GenerateImage, - GenerateVideo, - GetPageContents, - Glob, - Grep, - Knowledge, - KnowledgeBase, - ManageMcpTool, - ManageSkill, - Media, - OpenResource, - Read as ReadTool, - Research, - ScheduledTask, - ScrapePage, - SearchLibraryDocs, - SearchOnline, - Superagent, - Table, - UserMemory, - UserTable, - Workflow, - WorkspaceFile, -} from '@/lib/copilot/generated/tool-catalog-v1' import type { ChatContext } from '@/stores/panel' const EDIT_CONTENT_TOOL_ID = 'edit_content' @@ -199,49 +166,3 @@ export const SUBAGENT_LABELS: Record = { file: 'File Agent', media: 'Media Agent', } as const - -interface ToolTitleMetadata { - title: string -} - -/** - * Fallback titles for tool calls when the stream did not provide one. - */ -export const TOOL_UI_METADATA: Record = { - [Glob.id]: { title: 'Finding files' }, - [Grep.id]: { title: 'Searching' }, - [ReadTool.id]: { title: 'Reading file' }, - [SearchOnline.id]: { title: 'Searching online' }, - [ScrapePage.id]: { title: 'Scraping page' }, - [GetPageContents.id]: { title: 'Getting page contents' }, - [SearchLibraryDocs.id]: { title: 'Searching library docs' }, - [ManageMcpTool.id]: { title: 'MCP server action' }, - [ManageSkill.id]: { title: 'Skill action' }, - [UserMemory.id]: { title: 'Accessing memory' }, - [FunctionExecute.id]: { title: 'Running code' }, - [Superagent.id]: { title: 'Executing action' }, - [UserTable.id]: { title: 'Managing table' }, - [WorkspaceFile.id]: { title: 'Editing file' }, - [EDIT_CONTENT_TOOL_ID]: { title: 'Applying file content' }, - [CreateWorkflow.id]: { title: 'Creating workflow' }, - [EditWorkflow.id]: { title: 'Editing workflow' }, - [Workflow.id]: { title: 'Workflow Agent' }, - [RUN_SUBAGENT_ID]: { title: 'Run Agent' }, - [Deploy.id]: { title: 'Deploy Agent' }, - [Auth.id]: { title: 'Auth Agent' }, - [Knowledge.id]: { title: 'Knowledge Agent' }, - [KnowledgeBase.id]: { title: 'Managing knowledge base' }, - [Table.id]: { title: 'Table Agent' }, - [ScheduledTask.id]: { title: 'Scheduled Task Agent' }, - job: { title: 'Job Agent' }, - [Agent.id]: { title: 'Tools Agent' }, - custom_tool: { title: 'Creating tool' }, - [Research.id]: { title: 'Research Agent' }, - [OpenResource.id]: { title: 'Opening resource' }, - [Media.id]: { title: 'Media Agent' }, - [GenerateImage.id]: { title: 'Generating image' }, - [GenerateVideo.id]: { title: 'Generating video' }, - [GenerateAudio.id]: { title: 'Generating audio' }, - [Ffmpeg.id]: { title: 'Processing media' }, - context_compaction: { title: 'Compacted context' }, -} diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index f615448a356..ae971047208 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -14,6 +14,7 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' interface StreamSnapshotLike { events: StreamBatchEvent[] @@ -60,15 +61,6 @@ function buildInlineErrorTag(payload: MothershipStreamV1ErrorPayload): string { })}` } -function resolveToolDisplayTitle(ui: unknown): string | undefined { - if (!isRecordLike(ui)) return undefined - return typeof ui.title === 'string' - ? ui.title - : typeof ui.phaseLabel === 'string' - ? ui.phaseLabel - : undefined -} - function appendTextBlock( blocks: RawPersistedBlock[], content: string, @@ -277,7 +269,6 @@ function buildLiveAssistantMessage(params: { case MothershipStreamV1EventType.tool: { const payload = parsed.payload const toolCallId = payload.toolCallId - const displayTitle = resolveToolDisplayTitle('ui' in payload ? payload.ui : undefined) if ('previewPhase' in payload) { continue @@ -312,7 +303,10 @@ function buildLiveAssistantMessage(params: { calledBy: scopedSubagent, ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), ...spanIdentity, - displayTitle, + displayTitle: getToolDisplayTitle( + payload.toolName, + isRecordLike(payload.arguments) ? payload.arguments : undefined + ), params: isRecordLike(payload.arguments) ? payload.arguments : undefined, state: typeof payload.status === 'string' ? payload.status : 'executing', }) diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 91059cbbd2f..6081954a204 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -1160,9 +1160,6 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { enum: ['call'], type: 'string', }, - requiresConfirmation: { - type: 'boolean', - }, status: { $ref: '#/$defs/MothershipStreamV1ToolStatus', }, @@ -1307,21 +1304,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { hidden: { type: 'boolean', }, - icon: { - type: 'string', - }, internal: { type: 'boolean', }, - phaseLabel: { - type: 'string', - }, - requiresConfirmation: { - type: 'boolean', - }, - title: { - type: 'string', - }, }, type: 'object', }, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 32ca1d88d51..81e98257ddd 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -148,7 +148,6 @@ export interface MothershipStreamV1ToolCallDescriptor { mode: MothershipStreamV1ToolMode partial?: boolean phase: 'call' - requiresConfirmation?: boolean status?: MothershipStreamV1ToolStatus toolCallId: string toolName: string @@ -160,11 +159,7 @@ export interface MothershipStreamV1AdditionalPropertiesMap { export interface MothershipStreamV1ToolUI { clientExecutable?: boolean hidden?: boolean - icon?: string internal?: boolean - phaseLabel?: string - requiresConfirmation?: boolean - title?: string } export interface MothershipStreamV1ToolArgsDeltaEventEnvelope { payload: MothershipStreamV1ToolArgsDeltaPayload diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 0cd92c61c81..4a4c24a1594 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -14,12 +14,10 @@ export interface ToolCatalogEntry { | 'crawl_website' | 'create_file' | 'create_file_folder' - | 'create_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' | 'delete_file_folder' - | 'delete_folder' | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' @@ -52,7 +50,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'knowledge_base' | 'list_file_folders' - | 'list_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -60,6 +57,7 @@ export interface ToolCatalogEntry { | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' + | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -67,7 +65,6 @@ export interface ToolCatalogEntry { | 'media' | 'move_file' | 'move_file_folder' - | 'move_folder' | 'move_workflow' | 'oauth_get_auth_link' | 'oauth_request_access' @@ -115,12 +112,10 @@ export interface ToolCatalogEntry { | 'crawl_website' | 'create_file' | 'create_file_folder' - | 'create_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' | 'delete_file_folder' - | 'delete_folder' | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' @@ -153,7 +148,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'knowledge_base' | 'list_file_folders' - | 'list_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -161,6 +155,7 @@ export interface ToolCatalogEntry { | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' + | 'manage_folder' | 'manage_mcp_tool' | 'manage_scheduled_task' | 'manage_skill' @@ -168,7 +163,6 @@ export interface ToolCatalogEntry { | 'media' | 'move_file' | 'move_file_folder' - | 'move_folder' | 'move_workflow' | 'oauth_get_auth_link' | 'oauth_request_access' @@ -208,7 +202,6 @@ export interface ToolCatalogEntry { | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'read' | 'write' - requiresConfirmation?: boolean resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: @@ -405,23 +398,6 @@ export const CreateFileFolder: ToolCatalogEntry = { requiredPermission: 'write', } -export const CreateFolder: ToolCatalogEntry = { - id: 'create_folder', - name: 'create_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'Folder name.' }, - parentId: { type: 'string', description: 'Optional parent folder ID.' }, - workspaceId: { type: 'string', description: 'Optional workspace ID.' }, - }, - required: ['name'], - }, - requiredPermission: 'write', -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -467,7 +443,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { }, required: ['name'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -515,27 +490,6 @@ export const DeleteFileFolder: ToolCatalogEntry = { }, required: ['paths'], }, - requiresConfirmation: true, - requiredPermission: 'write', -} - -export const DeleteFolder: ToolCatalogEntry = { - id: 'delete_folder', - name: 'delete_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - folderIds: { - type: 'array', - description: 'The folder IDs to delete.', - items: { type: 'string' }, - }, - }, - required: ['folderIds'], - }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -555,7 +509,6 @@ export const DeleteWorkflow: ToolCatalogEntry = { }, required: ['workflowIds'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -571,7 +524,6 @@ export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { }, required: ['serverId'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -672,7 +624,6 @@ export const DeployApi: ToolCatalogEntry = { 'examples', ], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -818,7 +769,6 @@ export const DeployChat: ToolCatalogEntry = { 'examples', ], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -911,7 +861,6 @@ export const DeployMcp: ToolCatalogEntry = { }, required: ['deploymentType', 'deploymentStatus'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -1465,7 +1414,6 @@ export const GenerateApiKey: ToolCatalogEntry = { }, required: ['name'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -2346,7 +2294,6 @@ export const KnowledgeBase: ToolCatalogEntry = { }, required: ['success', 'message'], }, - requiresConfirmation: true, } export const ListFileFolders: ToolCatalogEntry = { @@ -2366,19 +2313,6 @@ export const ListFileFolders: ToolCatalogEntry = { requiredPermission: 'read', } -export const ListFolders: ToolCatalogEntry = { - id: 'list_folders', - name: 'list_folders', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workspaceId: { type: 'string', description: 'Optional workspace ID to list folders for.' }, - }, - }, -} - export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', @@ -2442,7 +2376,6 @@ export const LoadDeployment: ToolCatalogEntry = { }, required: ['version'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -2488,7 +2421,6 @@ export const ManageCredential: ToolCatalogEntry = { }, required: ['operation'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -2558,7 +2490,50 @@ export const ManageCustomTool: ToolCatalogEntry = { }, required: ['operation'], }, - requiresConfirmation: true, + requiredPermission: 'write', +} + +export const ManageFolder: ToolCatalogEntry = { + id: 'manage_folder', + name: 'manage_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + destinationPath: { + type: 'string', + description: + 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', + }, + folderId: { + type: 'string', + description: + 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', + }, + name: { + type: 'string', + description: + 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', + }, + operation: { + type: 'string', + description: 'The operation to perform.', + enum: ['create', 'rename', 'move', 'delete'], + }, + parentId: { + type: 'string', + description: + 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + }, + path: { + type: 'string', + description: + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + }, + }, + required: ['operation'], + }, requiredPermission: 'write', } @@ -2610,7 +2585,6 @@ export const ManageMcpTool: ToolCatalogEntry = { }, required: ['operation'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -2723,7 +2697,6 @@ export const ManageSkill: ToolCatalogEntry = { }, required: ['operation'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -2820,26 +2793,6 @@ export const MoveFileFolder: ToolCatalogEntry = { requiredPermission: 'write', } -export const MoveFolder: ToolCatalogEntry = { - id: 'move_folder', - name: 'move_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - folderId: { type: 'string', description: 'The folder ID to move.' }, - parentId: { - type: 'string', - description: - 'Target parent folder ID. Omit or pass empty string to move to workspace root.', - }, - }, - required: ['folderId'], - }, - requiredPermission: 'write', -} - export const MoveWorkflow: ToolCatalogEntry = { id: 'move_workflow', name: 'move_workflow', @@ -2897,7 +2850,6 @@ export const OauthRequestAccess: ToolCatalogEntry = { }, required: ['providerName'], }, - requiresConfirmation: true, } export const OpenResource: ToolCatalogEntry = { @@ -2955,7 +2907,6 @@ export const PromoteToLive: ToolCatalogEntry = { }, required: ['version'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -3161,7 +3112,6 @@ export const Redeploy: ToolCatalogEntry = { 'examples', ], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -3286,7 +3236,6 @@ export const RestoreResource: ToolCatalogEntry = { }, required: ['type', 'id'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -3342,7 +3291,6 @@ export const RunBlock: ToolCatalogEntry = { required: ['blockId'], }, clientExecutable: true, - requiresConfirmation: true, } export const RunFromBlock: ToolCatalogEntry = { @@ -3377,7 +3325,6 @@ export const RunFromBlock: ToolCatalogEntry = { required: ['startBlockId'], }, clientExecutable: true, - requiresConfirmation: true, } export const RunWorkflow: ToolCatalogEntry = { @@ -3421,7 +3368,6 @@ export const RunWorkflow: ToolCatalogEntry = { }, }, clientExecutable: true, - requiresConfirmation: true, } export const RunWorkflowUntilBlock: ToolCatalogEntry = { @@ -3470,7 +3416,6 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { required: ['stopAfterBlockId'], }, clientExecutable: true, - requiresConfirmation: true, } export const ScheduledTask: ToolCatalogEntry = { @@ -3666,7 +3611,6 @@ export const SetEnvironmentVariables: ToolCatalogEntry = { }, required: ['variables'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -3706,7 +3650,6 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { }, required: ['operations'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -3772,7 +3715,6 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { }, required: ['version'], }, - requiresConfirmation: true, requiredPermission: 'write', } @@ -3810,7 +3752,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { }, required: ['serverId'], }, - requiresConfirmation: true, requiredPermission: 'admin', } @@ -4189,7 +4130,6 @@ export const UserTable: ToolCatalogEntry = { }, required: ['success', 'message'], }, - requiresConfirmation: true, } export const Workflow: ToolCatalogEntry = { @@ -4439,6 +4379,23 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const +export const ManageFolderOperation = { + create: 'create', + rename: 'rename', + move: 'move', + delete: 'delete', +} as const + +export type ManageFolderOperation = + (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] + +export const ManageFolderOperationValues = [ + ManageFolderOperation.create, + ManageFolderOperation.rename, + ManageFolderOperation.move, + ManageFolderOperation.delete, +] as const + export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -4615,12 +4572,10 @@ export const TOOL_CATALOG: Record = { [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, [CreateFileFolder.id]: CreateFileFolder, - [CreateFolder.id]: CreateFolder, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteFile.id]: DeleteFile, [DeleteFileFolder.id]: DeleteFileFolder, - [DeleteFolder.id]: DeleteFolder, [DeleteWorkflow.id]: DeleteWorkflow, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, @@ -4653,7 +4608,6 @@ export const TOOL_CATALOG: Record = { [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, [ListFileFolders.id]: ListFileFolders, - [ListFolders.id]: ListFolders, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -4661,6 +4615,7 @@ export const TOOL_CATALOG: Record = { [LoadIntegrationTool.id]: LoadIntegrationTool, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, + [ManageFolder.id]: ManageFolder, [ManageMcpTool.id]: ManageMcpTool, [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, @@ -4668,7 +4623,6 @@ export const TOOL_CATALOG: Record = { [Media.id]: Media, [MoveFile.id]: MoveFile, [MoveFileFolder.id]: MoveFileFolder, - [MoveFolder.id]: MoveFolder, [MoveWorkflow.id]: MoveWorkflow, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 31171237e7e..dcaea0db6ea 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -180,27 +180,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_folder: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Folder name.', - }, - parentId: { - type: 'string', - description: 'Optional parent folder ID.', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID.', - }, - }, - required: ['name'], - }, - resultSchema: undefined, - }, create_workflow: { parameters: { type: 'object', @@ -305,22 +284,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_folder: { - parameters: { - type: 'object', - properties: { - folderIds: { - type: 'array', - description: 'The folder IDs to delete.', - items: { - type: 'string', - }, - }, - }, - required: ['folderIds'], - }, - resultSchema: undefined, - }, delete_workflow: { parameters: { type: 'object', @@ -2154,18 +2117,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_folders: { - parameters: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Optional workspace ID to list folders for.', - }, - }, - }, - resultSchema: undefined, - }, list_integration_tools: { parameters: { properties: { @@ -2345,6 +2296,45 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + manage_folder: { + parameters: { + type: 'object', + properties: { + destinationPath: { + type: 'string', + description: + 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', + }, + folderId: { + type: 'string', + description: + 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', + }, + name: { + type: 'string', + description: + 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', + }, + operation: { + type: 'string', + description: 'The operation to perform.', + enum: ['create', 'rename', 'move', 'delete'], + }, + parentId: { + type: 'string', + description: + 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + }, + path: { + type: 'string', + description: + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, manage_mcp_tool: { parameters: { type: 'object', @@ -2581,24 +2571,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_folder: { - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: 'The folder ID to move.', - }, - parentId: { - type: 'string', - description: - 'Target parent folder ID. Omit or pass empty string to move to workspace root.', - }, - }, - required: ['folderId'], - }, - resultSchema: undefined, - }, move_workflow: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 8d4cb83c669..7d341238c4e 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -168,10 +168,7 @@ describe('sse-handlers tool lifecycle', () => { executor: MothershipStreamV1ToolExecutor.sim, mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.call, - ui: { - title: 'Reading foo.txt', - phaseLabel: 'Workspace', - }, + ui: {}, }, } satisfies StreamEvent, context, @@ -197,53 +194,16 @@ describe('sse-handlers tool lifecycle', () => { const updated = context.toolCalls.get('tool-1') expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success) - expect(updated?.displayTitle).toBe('Reading foo.txt') + // Display titles are derived client-side from the tool name (+args), not the + // stream; read with no path resolves to the static "Reading file". + expect(updated?.displayTitle).toBe('Reading file') expect(updated?.result?.output).toEqual({ ok: true }) expect(context.contentBlocks.at(0)).toEqual( expect.objectContaining({ type: 'tool_call', toolCall: expect.objectContaining({ id: 'tool-1', - displayTitle: 'Reading foo.txt', - }), - }) - ) - }) - - it('uses phaseLabel as a display title fallback when no title is provided', async () => { - executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) - const onEvent = vi.fn() - - await sseHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'tool-phase-label', - toolName: ReadTool.id, - arguments: { workflowId: 'workflow-1' }, - executor: MothershipStreamV1ToolExecutor.sim, - mode: MothershipStreamV1ToolMode.async, - phase: MothershipStreamV1ToolPhase.call, - ui: { - phaseLabel: 'Workspace', - }, - }, - } satisfies StreamEvent, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - await sleep(0) - - const updated = context.toolCalls.get('tool-phase-label') - expect(updated?.displayTitle).toBe('Workspace') - expect(context.contentBlocks.at(0)).toEqual( - expect.objectContaining({ - type: 'tool_call', - toolCall: expect.objectContaining({ - id: 'tool-phase-label', - displayTitle: 'Workspace', + displayTitle: 'Reading file', }), }) ) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 7e76a57b2f3..be028b2b1fc 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -30,6 +30,7 @@ import type { } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import type { ToolScope } from './types' import { @@ -50,13 +51,12 @@ import { const logger = createLogger('CopilotToolHandler') -function applyToolDisplay( - toolCall: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string; hidden?: boolean } -): void { - if (!toolCall) return - const displayTitle = ui.title || ui.phaseLabel - if (displayTitle) toolCall.displayTitle = displayTitle +function applyToolDisplay(toolCall: ToolCallState | undefined): void { + if (!toolCall?.name) return + toolCall.displayTitle = getToolDisplayTitle( + toolCall.name, + toolCall.params as Record | undefined + ) } /** @@ -262,7 +262,7 @@ async function handleCallPhase( if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (existing && !existing.name && toolName) existing.name = toolName if (existing && !existing.params && args) existing.params = args - applyToolDisplay(existing, ui) + applyToolDisplay(existing) return } } else { @@ -272,7 +272,7 @@ async function handleCallPhase( ) { if (!existing.name && toolName) existing.name = toolName if (!existing.params && args) existing.params = args - applyToolDisplay(existing, ui) + applyToolDisplay(existing) return } } @@ -377,7 +377,7 @@ function registerSubagentToolCall( if (toolCall) { if (!toolCall.name && toolName) toolCall.name = toolName if (args && !toolCall.params) toolCall.params = args - applyToolDisplay(toolCall, ui) + applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) } else { toolCall = { @@ -387,7 +387,7 @@ function registerSubagentToolCall( params: args, startTime: Date.now(), } - applyToolDisplay(toolCall, ui) + applyToolDisplay(toolCall) context.toolCalls.set(toolCallId, toolCall) const parentToolCall = context.toolCalls.get(parentToolCallId) if (!hideFromUi) { @@ -406,7 +406,7 @@ function registerSubagentToolCall( if (existingSubagentToolCall) { if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args - applyToolDisplay(existingSubagentToolCall, ui) + applyToolDisplay(existingSubagentToolCall) } else { subagentToolCalls.push(toolCall) } @@ -423,7 +423,7 @@ function registerMainToolCall( const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { if (args && !existing.params) existing.params = args - applyToolDisplay(existing, ui) + applyToolDisplay(existing) if (hideFromUi) { removeToolCallContentBlock(context, toolCallId) return @@ -442,7 +442,7 @@ function registerMainToolCall( params: args, startTime: Date.now(), } - applyToolDisplay(created, ui) + applyToolDisplay(created) context.toolCalls.set(toolCallId, created) if (!hideFromUi) { addContentBlock(context, { type: 'tool_call', toolCall: created }) diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index 21543ce5d60..e35c1ba5efc 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -167,28 +167,23 @@ export function abortPendingToolIfStreamDead( } /** - * Extract the `ui` object from a typed tool_call payload. The Go backend enriches - * tool_call events with `ui: { requiresConfirmation, clientExecutable, ... }`. + * Extract the behavioral `ui` flags from a typed tool_call payload. The Go + * backend enriches tool_call events with `ui: { clientExecutable, internal, + * hidden }`; presentation (title/icon) is derived client-side from the tool name. */ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { - requiresConfirmation: boolean clientExecutable: boolean simExecutable: boolean internal: boolean hidden: boolean - title?: string - phaseLabel?: string } { const raw = asRecord(data.ui) return { - requiresConfirmation: raw.requiresConfirmation === true || data.requiresConfirmation === true, clientExecutable: raw.clientExecutable === true || data.executor === MothershipStreamV1ToolExecutor.client, simExecutable: data.executor === MothershipStreamV1ToolExecutor.sim, internal: raw.internal === true, hidden: raw.hidden === true, - title: typeof raw.title === 'string' ? raw.title : undefined, - phaseLabel: typeof raw.phaseLabel === 'string' ? raw.phaseLabel : undefined, } } diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 48784f1a6bb..b39027e3526 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -2,10 +2,8 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, CompleteScheduledTask, - CreateFolder, CreateWorkflow, CreateWorkspaceMcpServer, - DeleteFolder, DeleteWorkflow, DeleteWorkspaceMcpServer, DeployApi, @@ -23,18 +21,17 @@ import { GetWorkflowRunOptions, Glob as GlobTool, Grep as GrepTool, - ListFolders, ListIntegrationTools, ListUserWorkspaces, ListWorkspaceMcpServers, LoadDeployment, ManageCredential, ManageCustomTool, + ManageFolder, ManageMcpTool, ManageScheduledTask, ManageSkill, MaterializeFile, - MoveFolder, MoveWorkflow, OauthGetAuthLink, OauthRequestAccess, @@ -92,12 +89,10 @@ import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { - executeCreateFolder, executeCreateWorkflow, - executeDeleteFolder, executeDeleteWorkflow, executeGenerateApiKey, - executeMoveFolder, + executeManageFolder, executeMoveWorkflow, executeRenameWorkflow, executeRunBlock, @@ -113,7 +108,6 @@ import { executeGetDeployedWorkflowState, executeGetWorkflowData, executeGetWorkflowRunOptions, - executeListFolders, executeListUserWorkspaces, } from '../tools/handlers/workflow/queries' import { registerHandlers } from './executor' @@ -140,7 +134,6 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), - [ListFolders.id]: h(executeListFolders), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), @@ -148,12 +141,10 @@ function buildHandlerMap(): Record { [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), [CreateWorkflow.id]: h(executeCreateWorkflow), - [CreateFolder.id]: h(executeCreateFolder), [DeleteWorkflow.id]: h(executeDeleteWorkflow), - [DeleteFolder.id]: h(executeDeleteFolder), [RenameWorkflow.id]: h(executeRenameWorkflow), [MoveWorkflow.id]: h(executeMoveWorkflow), - [MoveFolder.id]: h(executeMoveFolder), + [ManageFolder.id]: h(executeManageFolder), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index b0c28e25b7a..da437b4993a 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -27,10 +27,6 @@ export interface GetBlockUpstreamReferencesParams { blockIds: string[] } -export interface ListFoldersParams { - workspaceId?: string -} - // === Workflow Mutation Params === export interface CreateWorkflowParams { @@ -262,6 +258,15 @@ export interface DeleteFolderParams { folderIds: string[] } +export interface ManageFolderParams { + operation: string + path?: string + folderId?: string + name?: string + destinationPath?: string + parentId?: string | null +} + export interface UpdateWorkspaceMcpServerParams { serverId: string name?: string diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 7ae8c2e6a4d..e72abc1082d 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -8,6 +8,11 @@ import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblock import { eq } from 'drizzle-orm' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { + buildVfsFolderPathMap, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' @@ -307,6 +312,7 @@ import type { DeleteFolderParams, DeleteWorkflowParams, GenerateApiKeyParams, + ManageFolderParams, MoveFolderParams, MoveWorkflowParams, RenameFolderParams, @@ -1242,6 +1248,152 @@ async function executeRenameFolder( } } +/** + * Strip the `workflows/` VFS prefix from a folder path, returning the + * folder-relative remainder. `workflows` (or an empty path) maps to the + * workspace root and yields an empty string. + */ +function workflowFolderRelativePath(rawPath: string): string { + const trimmed = rawPath.trim().replace(/^\/+|\/+$/g, '') + if (!trimmed || trimmed === 'workflows') return '' + return trimmed.startsWith('workflows/') ? trimmed.slice('workflows/'.length) : trimmed +} + +/** + * Load a lookup from each folder's canonical encoded VFS path to its id by + * inverting the same {@link buildVfsFolderPathMap} the VFS uses to serve folder + * paths, so a path the agent sees via glob round-trips to the right id. Fetched + * once per manage_folder call and reused across target + parent resolution. + */ +async function loadFolderPathToIdMap(workspaceId: string): Promise> { + const byPath = new Map() + for (const [folderId, encodedPath] of buildVfsFolderPathMap( + await listFolders(workspaceId) + ).entries()) { + byPath.set(encodedPath, folderId) + } + return byPath +} + +function lookupFolderIdByPath(rawPath: string, byPath: Map): string | null { + const relative = workflowFolderRelativePath(rawPath) + if (!relative) return null + return byPath.get(encodeVfsPathSegments(decodeVfsPathSegments(relative))) ?? null +} + +/** Resolve the folder a manage_folder op targets, preferring folderId over path. */ +async function resolveManageFolderTarget( + params: ManageFolderParams, + getFolderPaths: () => Promise> +): Promise<{ folderId: string } | { error: string }> { + const directId = typeof params.folderId === 'string' ? params.folderId.trim() : '' + if (directId) return { folderId: directId } + const path = typeof params.path === 'string' ? params.path.trim() : '' + if (!path) return { error: 'Provide the folder path (e.g. "workflows/Marketing") or folderId.' } + const folderId = lookupFolderIdByPath(path, await getFolderPaths()) + if (!folderId) return { error: `Folder not found at ${path}` } + return { folderId } +} + +/** + * Resolve the destination parent for move/create. parentId/destinationPath are + * optional; their absence (or an explicit root) targets the workspace root + * (parentId null). + */ +async function resolveManageFolderParent( + params: ManageFolderParams, + getFolderPaths: () => Promise> +): Promise<{ parentId: string | null } | { error: string }> { + const directId = typeof params.parentId === 'string' ? params.parentId.trim() : '' + if (directId) return { parentId: directId } + if (params.parentId === null) return { parentId: null } + const dest = typeof params.destinationPath === 'string' ? params.destinationPath.trim() : '' + if (!dest || !workflowFolderRelativePath(dest)) return { parentId: null } + const parentId = lookupFolderIdByPath(dest, await getFolderPaths()) + if (!parentId) return { error: `Destination folder not found at ${dest}` } + return { parentId } +} + +/** + * Single entry point for folder CRUD (create/rename/move/delete). Resolves the + * VFS-path/folderId handles, then delegates to the existing folder handlers so + * all DB orchestration (performCreateFolder / performUpdateFolder / + * performDeleteFolder) stays in one place. + */ +export async function executeManageFolder( + params: ManageFolderParams, + context: ExecutionContext +): Promise { + try { + const operation = typeof params?.operation === 'string' ? params.operation.trim() : '' + const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + + // Fetch the workspace folder list at most once, lazily — only when a path + // (vs an explicit id) actually needs resolving, and shared across the + // target + parent lookups a single move/create performs. + let folderPathsPromise: Promise> | undefined + const getFolderPaths = () => (folderPathsPromise ??= loadFolderPathToIdMap(workspaceId)) + + switch (operation) { + case 'create': { + let name = typeof params.name === 'string' ? params.name.trim() : '' + let parentId: string | null = null + const path = typeof params.path === 'string' ? params.path.trim() : '' + if (!name && path) { + const segments = decodeVfsPathSegments(workflowFolderRelativePath(path)) + if (segments.length === 0) { + return { success: false, error: 'create requires a folder name or path' } + } + name = segments[segments.length - 1] + const parentSegments = segments.slice(0, -1) + if (parentSegments.length > 0) { + const resolved = lookupFolderIdByPath( + encodeVfsPathSegments(parentSegments), + await getFolderPaths() + ) + if (!resolved) { + return { success: false, error: `Parent folder not found for ${path}` } + } + parentId = resolved + } + } else { + const parent = await resolveManageFolderParent(params, getFolderPaths) + if ('error' in parent) return { success: false, error: parent.error } + parentId = parent.parentId + } + if (!name) return { success: false, error: 'create requires a folder name or path' } + return executeCreateFolder({ name, parentId: parentId ?? undefined, workspaceId }, context) + } + case 'rename': { + const name = typeof params.name === 'string' ? params.name.trim() : '' + if (!name) return { success: false, error: 'rename requires a new name' } + const target = await resolveManageFolderTarget(params, getFolderPaths) + if ('error' in target) return { success: false, error: target.error } + return executeRenameFolder({ folderId: target.folderId, name }, context) + } + case 'move': { + const target = await resolveManageFolderTarget(params, getFolderPaths) + if ('error' in target) return { success: false, error: target.error } + const parent = await resolveManageFolderParent(params, getFolderPaths) + if ('error' in parent) return { success: false, error: parent.error } + return executeMoveFolder({ folderId: target.folderId, parentId: parent.parentId }, context) + } + case 'delete': { + const target = await resolveManageFolderTarget(params, getFolderPaths) + if ('error' in target) return { success: false, error: target.error } + return executeDeleteFolder({ folderIds: [target.folderId] }, context) + } + default: + return { + success: false, + error: `Unknown operation "${operation}". Use create, rename, move, or delete.`, + } + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + export async function executeRunBlock( params: RunBlockParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41ea582105f..f7b556ca453 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -14,19 +14,18 @@ import { } from '@/lib/workflows/persistence/utils' import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { getWorkflowById, listFolders } from '@/lib/workflows/utils' +import { getWorkflowById } from '@/lib/workflows/utils' import { listUserWorkspaces } from '@/lib/workspaces/utils' import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' -import { ensureWorkflowAccess, ensureWorkspaceAccess, getDefaultWorkspaceId } from '../access' +import { ensureWorkflowAccess } from '../access' import type { GetBlockOutputsParams, GetBlockUpstreamReferencesParams, GetDeployedWorkflowStateParams, GetWorkflowDataParams, GetWorkflowRunOptionsParams, - ListFoldersParams, } from '../param-types' export async function executeListUserWorkspaces( @@ -41,32 +40,6 @@ export async function executeListUserWorkspaces( } } -export async function executeListFolders( - params: ListFoldersParams, - context: ExecutionContext -): Promise { - try { - const workspaceId = - (params?.workspaceId as string | undefined) || - context.workspaceId || - (await getDefaultWorkspaceId(context.userId)) - - await ensureWorkspaceAccess(workspaceId, context.userId, 'read') - - const folders = await listFolders(workspaceId) - - return { - success: true, - output: { - workspaceId, - folders, - }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - export async function executeGetWorkflowRunOptions( params: GetWorkflowRunOptionsParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts deleted file mode 100644 index 8adede9671b..00000000000 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ /dev/null @@ -1,535 +0,0 @@ -import type { Tool } from '@modelcontextprotocol/sdk/types.js' - -export type ToolAnnotations = NonNullable - -export type DirectToolDef = { - name: string - description: string - inputSchema: Tool['inputSchema'] - toolId: string - annotations?: ToolAnnotations -} - -export type SubagentToolDef = { - name: string - description: string - inputSchema: Tool['inputSchema'] - agentId: string - annotations?: ToolAnnotations -} - -/** - * Direct tools that execute immediately without LLM orchestration. - * These are fast database queries that don't need AI reasoning. - */ -export const DIRECT_TOOL_DEFS: DirectToolDef[] = [ - { - name: 'list_workspaces', - toolId: 'list_user_workspaces', - description: - 'List all workspaces the user has access to. Returns workspace IDs, names, and roles. Use this first to determine which workspace to operate in.', - inputSchema: { - type: 'object', - properties: {}, - }, - annotations: { readOnlyHint: true }, - }, - { - name: 'list_folders', - toolId: 'list_folders', - description: - 'List all folders in a workspace. Returns folder IDs, names, and parent relationships for organizing workflows.', - inputSchema: { - type: 'object', - properties: { - workspaceId: { - type: 'string', - description: 'Workspace ID to list folders from.', - }, - }, - required: ['workspaceId'], - }, - annotations: { readOnlyHint: true }, - }, - { - name: 'create_workflow', - toolId: 'create_workflow', - description: - 'Create a new empty workflow. Returns the new workflow ID. Always call this FIRST before sim_workflow for new workflows. Use workspaceId to place it in a specific workspace.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name for the new workflow.', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Uses default workspace if not provided.', - }, - folderId: { - type: 'string', - description: 'Optional folder ID to place the workflow in.', - }, - description: { - type: 'string', - description: 'Optional description for the workflow.', - }, - }, - required: ['name'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'create_folder', - toolId: 'create_folder', - description: - 'Create a new folder for organizing workflows. Use parentId to create nested folder hierarchies.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name for the new folder.', - }, - workspaceId: { - type: 'string', - description: 'Optional workspace ID. Uses default workspace if not provided.', - }, - parentId: { - type: 'string', - description: 'Optional parent folder ID for nested folders.', - }, - }, - required: ['name'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'rename_workflow', - toolId: 'rename_workflow', - description: 'Rename an existing workflow.', - inputSchema: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'The workflow ID to rename.', - }, - name: { - type: 'string', - description: 'The new name for the workflow.', - }, - }, - required: ['workflowId', 'name'], - }, - annotations: { destructiveHint: false, idempotentHint: true }, - }, - { - name: 'move_workflow', - toolId: 'move_workflow', - description: - 'Move a workflow into a different folder. Omit folderId or pass empty string to move to workspace root.', - inputSchema: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'The workflow ID to move.', - }, - folderId: { - type: 'string', - description: 'Target folder ID. Omit or pass empty string to move to workspace root.', - }, - }, - required: ['workflowId'], - }, - annotations: { destructiveHint: false, idempotentHint: true }, - }, - { - name: 'move_folder', - toolId: 'move_folder', - description: - 'Move a folder into another folder. Omit parentId or pass empty string to move to workspace root.', - inputSchema: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: 'The folder ID to move.', - }, - parentId: { - type: 'string', - description: - 'Target parent folder ID. Omit or pass empty string to move to workspace root.', - }, - }, - required: ['folderId'], - }, - annotations: { destructiveHint: false, idempotentHint: true }, - }, - { - name: 'get_deployed_workflow_state', - toolId: 'get_deployed_workflow_state', - description: - 'Get the deployed (production) state of a workflow. Returns the full workflow definition as deployed, or indicates if the workflow is not yet deployed.', - inputSchema: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'REQUIRED. The workflow ID to get the deployed state for.', - }, - }, - required: ['workflowId'], - }, - annotations: { readOnlyHint: true }, - }, - { - name: 'generate_api_key', - toolId: 'generate_api_key', - description: - 'Generate a new workspace API key for calling workflow API endpoints. The key is only shown once — tell the user to save it immediately.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: - 'A descriptive name for the API key (e.g., "production-key", "dev-testing").', - }, - workspaceId: { - type: 'string', - description: "Optional workspace ID. Defaults to user's default workspace.", - }, - }, - required: ['name'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'create_job', - toolId: 'create_job', - description: - 'Create a scheduled background job that runs a prompt against Sim at a specified frequency or time. Use for polling, reminders, or deferred tasks. Provide cron for recurring jobs or time for one-time execution.', - inputSchema: { - type: 'object', - properties: { - title: { - type: 'string', - description: 'A short descriptive title for the job (e.g., "Email Poller").', - }, - prompt: { - type: 'string', - description: 'The prompt to execute when the job fires.', - }, - cron: { - type: 'string', - description: - 'Cron expression for recurring jobs (e.g., "*/5 * * * *" for every 5 minutes).', - }, - time: { - type: 'string', - description: - 'ISO 8601 datetime for one-time jobs or cron start time (e.g., "2026-03-06T09:00:00").', - }, - timezone: { - type: 'string', - description: 'IANA timezone (default: UTC).', - }, - lifecycle: { - type: 'string', - description: - '"persistent" (default, runs indefinitely) or "until_complete" (runs until complete_scheduled_task is called).', - }, - successCondition: { - type: 'string', - description: - 'What must happen for the job to be considered complete. Used with until_complete lifecycle.', - }, - maxRuns: { - type: 'number', - description: 'Maximum number of executions before the job auto-completes. Safety limit.', - }, - }, - required: ['title', 'prompt'], - }, - annotations: { destructiveHint: false }, - }, -] - -export const SUBAGENT_TOOL_DEFS: SubagentToolDef[] = [ - { - name: 'sim_workflow', - agentId: 'workflow', - description: `Create, modify, test, debug, and organize workflows end-to-end in a single step. - -USE THIS WHEN: -- Building a new workflow from scratch -- Modifying an existing workflow -- You want to gather information and build in one pass -- Moving, renaming, or organizing workflows and folders - -WORKFLOW ID (REQUIRED): -- For NEW workflows: First call create_workflow to get a workflowId, then pass it here -- For EXISTING workflows: Always pass the workflowId parameter - -CAN DO: -- Gather information about blocks, credentials, patterns -- Search documentation and patterns for best practices -- Add, modify, or remove blocks -- Configure block settings and connections -- Set environment variables and workflow variables -- Move, rename, delete workflows and folders -- Run or inspect workflows through the nested run/debug specialists when validation is needed -- Delegate deployment or auth setup to the nested specialists when needed - -CANNOT DO: -- Replace dedicated testing flows like sim_test when you want a standalone execution-only pass -- Replace dedicated deploy flows like sim_deploy when you want deployment as a separate step - -WORKFLOW: -1. Call create_workflow to get a workflowId (for new workflows) -2. Call sim_workflow with the request and workflowId -3. Workflow agent gathers info, builds, and can delegate run/debug/auth/deploy help in one pass -4. Call sim_test when you want a dedicated execution-only verification pass -5. Optionally call sim_deploy to make it externally accessible`, - inputSchema: { - type: 'object', - properties: { - request: { - type: 'string', - description: 'What you want to build, modify, or organize.', - }, - workflowId: { - type: 'string', - description: - 'REQUIRED. The workflow ID. For new workflows, call create_workflow first to get this.', - }, - context: { type: 'object' }, - }, - required: ['request', 'workflowId'], - }, - annotations: { destructiveHint: false, openWorldHint: true }, - }, - { - name: 'sim_discovery', - agentId: 'discovery', - description: `Find workflows by their contents or functionality when the user doesn't know the exact name or ID. - -USE THIS WHEN: -- User describes a workflow by what it does: "the one that sends emails", "my Slack notification workflow" -- User refers to workflow contents: "the workflow with the OpenAI block" -- User needs to search/match workflows by functionality or description - -DO NOT USE (use direct tools instead): -- User knows the workflow name → use get_workflow -- User wants to list all workflows → use list_workflows -- User wants to list workspaces → use list_workspaces -- User wants to list folders → use list_folders`, - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - workspaceId: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { readOnlyHint: true }, - }, - { - name: 'sim_deploy', - agentId: 'deploy', - description: `Deploy a workflow to make it accessible externally. Workflows can be tested without deploying, but deployment is needed for API access, chat UIs, or MCP exposure. - -DEPLOYMENT TYPES: -- "deploy as api" - REST API endpoint for programmatic access -- "deploy as chat" - Managed chat UI with auth options -- "deploy as mcp" - Expose as MCP tool on an MCP server for AI agents to call - -MCP DEPLOYMENT FLOW: -The deploy subagent will automatically: list available MCP servers → create one if needed → deploy the workflow as an MCP tool to that server. You can specify server name, tool name, and tool description. - -ALSO CAN: -- Get the deployed (production) state to compare with draft -- Generate workspace API keys for calling deployed workflows -- List and create MCP servers in the workspace`, - inputSchema: { - type: 'object', - properties: { - request: { - type: 'string', - description: 'The deployment request, e.g. "deploy as api" or "deploy as chat"', - }, - workflowId: { - type: 'string', - description: 'REQUIRED. The workflow ID to deploy.', - }, - context: { type: 'object' }, - }, - required: ['request', 'workflowId'], - }, - annotations: { destructiveHint: false, openWorldHint: true }, - }, - { - name: 'sim_test', - agentId: 'run', - description: `Run a workflow and verify its outputs. Works on both deployed and undeployed (draft) workflows. Use after building to verify correctness. - -Supports full and partial execution: -- Full run with test inputs -- Stop after a specific block (run_workflow_until_block) -- Run a single block in isolation (run_block) -- Resume from a specific block (run_from_block)`, - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - workflowId: { - type: 'string', - description: 'REQUIRED. The workflow ID to test.', - }, - context: { type: 'object' }, - }, - required: ['request', 'workflowId'], - }, - annotations: { destructiveHint: false, openWorldHint: true }, - }, - { - name: 'sim_auth', - agentId: 'auth', - description: - 'Check OAuth connection status, list connected services, and initiate new OAuth connections. Use when a workflow needs third-party service access (Google, Slack, GitHub, etc.). In MCP/headless mode, returns an authorization URL the user must open in their browser to complete the OAuth flow.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: false, openWorldHint: true }, - }, - { - name: 'sim_knowledge', - agentId: 'knowledge', - description: - 'Manage knowledge bases for RAG-powered document retrieval. Supports listing, creating, updating, and deleting knowledge bases. Knowledge bases can be attached to agent blocks for context-aware responses.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'sim_table', - agentId: 'table', - description: - 'Manage user-defined tables for structured data storage. Supports creating tables with typed schemas, inserting/updating/deleting rows, querying with filters and sorting, and batch operations.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'sim_job', - agentId: 'scheduled_task', - description: - 'Manage scheduled tasks. Supports creating, listing, updating, pausing, resuming, and deleting scheduled tasks that run prompts against Sim on a schedule or at a specific time.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'sim_agent', - agentId: 'agent', - description: - 'Manage custom tools, MCP server connections, and skills for agent blocks. Supports creating, editing, deleting, and listing custom JavaScript tools, external MCP server connections, and workspace skills. Can also research external MCP tools and add deployed workflows as MCP tools.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: false }, - }, - { - name: 'sim_info', - agentId: 'info', - description: - "Inspect a workflow's blocks, connections, outputs, variables, and metadata. Use for questions about the Sim platform itself — how blocks work, what integrations are available, platform concepts, etc. Provide workflowId when you want results scoped to a specific workflow.", - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - workflowId: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { readOnlyHint: true }, - }, - { - name: 'sim_research', - agentId: 'research', - description: - 'Research external APIs and documentation. Use when you need to understand third-party services, external APIs, authentication flows, or data formats OUTSIDE of Sim. For questions about Sim itself, use sim_info instead.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { readOnlyHint: true, openWorldHint: true }, - }, - { - name: 'sim_superagent', - agentId: 'superagent', - description: - 'Execute direct actions NOW: send an email, post to Slack, make an API call, etc. Use when the user wants to DO something immediately rather than build a workflow for it.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { destructiveHint: true, openWorldHint: true }, - }, - { - name: 'sim_platform', - agentId: 'tour', - description: - 'Get help with Sim platform navigation, keyboard shortcuts, and UI actions. Use when the user asks "how do I..." about the Sim editor, wants keyboard shortcuts, or needs to know what actions are available in the UI.', - inputSchema: { - type: 'object', - properties: { - request: { type: 'string' }, - context: { type: 'object' }, - }, - required: ['request'], - }, - annotations: { readOnlyHint: true }, - }, -] diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts new file mode 100644 index 00000000000..5ee25a69959 --- /dev/null +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -0,0 +1,223 @@ +import { stripVersionSuffix } from '@sim/utils/string' + +/** + * Single source of truth for copilot tool-call display titles. + * + * The mothership (Go) no longer emits any presentation metadata on the stream — + * tool-call titles are derived entirely here, keyed by tool name (plus arguments + * for the dynamic cases). The live client render layer (see + * `home/hooks/stream/stream-helpers.ts`) wraps this with workspace/block-name + * enrichment for the run_* tools; every other surface (server persistence, + * transcript replay, fallback rendering) calls `getToolDisplayTitle` directly. + * + * Icons are likewise client-owned — see `getToolIcon` in the message-content + * utils. Nothing about tool presentation lives on the Go side anymore. + */ + +type ToolArgs = Record | undefined + +function stringArg(args: ToolArgs, key: string): string { + const value = args?.[key] + return typeof value === 'string' ? value.trim() : '' +} + +function firstStringArg(args: ToolArgs, ...keys: string[]): string { + for (const key of keys) { + const value = stringArg(args, key) + if (value) return value + } + return '' +} + +function stringArrayArg(args: ToolArgs, key: string): string[] { + const value = args?.[key] + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) +} + +function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): string { + const parent = args?.[parentKey] + if (!parent || typeof parent !== 'object') return '' + return firstStringArg(parent as Record, ...keys) +} + +function operationTitle( + args: ToolArgs, + placeholder: string, + labels: Record +): string { + const operation = stringArg(args, 'operation') + return labels[operation] ?? placeholder +} + +function isWorkflowArtifactPath(path: string, filename: string): boolean { + const trimmed = path.trim() + return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) +} + +function workspaceFileTitle(args: ToolArgs): string { + const title = stringArg(args, 'title') + if (!title) return '' + const verbByOperation: Record = { + create: 'Creating', + append: 'Adding', + patch: 'Editing', + update: 'Writing', + rename: 'Renaming', + delete: 'Deleting', + } + const verb = verbByOperation[stringArg(args, 'operation')] ?? 'Writing' + return `${verb} ${title}` +} + +/** Static fallback titles for tools without an argument-aware title. */ +const TOOL_TITLES: Record = { + read: 'Reading file', + search_library_docs: 'Searching library docs', + user_memory: 'Accessing memory', + user_table: 'Managing table', + workspace_file: 'Editing file', + edit_content: 'Applying file content', + create_workflow: 'Creating workflow', + edit_workflow: 'Editing workflow', + knowledge_base: 'Managing knowledge base', + open_resource: 'Opening resource', + generate_image: 'Generating image', + generate_video: 'Generating video', + generate_audio: 'Generating audio', + ffmpeg: 'Processing media', + manage_folder: 'Folder action', + // Subagent trigger tools, when surfaced as a tool call. + workflow: 'Workflow Agent', + run: 'Run Agent', + deploy: 'Deploy Agent', + auth: 'Auth Agent', + knowledge: 'Knowledge Agent', + table: 'Table Agent', + scheduled_task: 'Scheduled Task Agent', + agent: 'Tools Agent', + research: 'Research Agent', + media: 'Media Agent', + superagent: 'Executing action', +} + +/** + * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage + * Folder"), matching the legacy client humanizer so labels never render blank. + */ +export function humanizeToolName(name: string): string { + const words = stripVersionSuffix(name).split('_').filter(Boolean) + if (words.length === 0) return name + return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} + +/** + * Resolve a tool-call display title from its name and arguments. Argument-aware + * cases come first, then the static map, then a humanized fallback. This never + * returns an empty string. + */ +export function getToolDisplayTitle(name: string, args?: Record): string { + switch (name) { + case 'search_online': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Searching online for ${target}` : 'Searching online' + } + case 'grep': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Searching for ${target}` : 'Searching' + } + case 'glob': { + const target = firstStringArg(args, 'toolTitle', 'title') + return target ? `Finding ${target}` : 'Finding files' + } + case 'enrichment_run': { + const subject = nestedStringArg( + args, + 'inputs', + 'fullName', + 'companyName', + 'domain', + 'email', + 'companyDomain' + ) + return subject ? `Searching for ${subject}` : 'Searching' + } + case 'scrape_page': { + const url = stringArg(args, 'url') + return url ? `Scraping ${url}` : 'Scraping page' + } + case 'crawl_website': { + const url = stringArg(args, 'url') + return url ? `Crawling ${url}` : 'Crawling website' + } + case 'get_page_contents': { + const urls = stringArrayArg(args, 'urls') + if (urls.length === 1) return `Getting ${urls[0]}` + if (urls.length > 1) return `Getting ${urls.length} pages` + return 'Getting page contents' + } + case 'manage_custom_tool': + return operationTitle(args, 'Custom tool action', { + add: 'Creating custom tool', + edit: 'Updating custom tool', + delete: 'Deleting custom tool', + list: 'Listing custom tools', + }) + case 'manage_mcp_tool': + return operationTitle(args, 'MCP server action', { + add: 'Creating MCP server', + edit: 'Updating MCP server', + delete: 'Deleting MCP server', + list: 'Listing MCP servers', + }) + case 'manage_skill': + return operationTitle(args, 'Skill action', { + add: 'Creating skill', + edit: 'Updating skill', + delete: 'Deleting skill', + list: 'Listing skills', + }) + case 'manage_scheduled_task': + return operationTitle(args, 'Scheduled task action', { + create: 'Creating scheduled task', + get: 'Getting scheduled task', + update: 'Updating scheduled task', + delete: 'Deleting scheduled task', + list: 'Listing scheduled tasks', + }) + case 'manage_credential': + return operationTitle(args, 'Credential action', { + rename: 'Renaming credential', + delete: 'Deleting credential', + }) + case 'manage_folder': + return operationTitle(args, 'Folder action', { + create: 'Creating folder', + rename: 'Renaming folder', + move: 'Moving folder', + delete: 'Deleting folder', + }) + case 'run_workflow': + case 'run_from_block': + case 'run_workflow_until_block': + return 'Running workflow' + case 'query_logs': { + const workflowName = stringArg(args, 'workflowName') + return workflowName ? `Querying logs for ${workflowName}` : 'Querying logs' + } + case 'read': { + if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { + return 'Validating workflow state' + } + break + } + case 'workspace_file': + case 'function_execute': { + const title = name === 'workspace_file' ? workspaceFileTitle(args) : stringArg(args, 'title') + if (title) return title + break + } + } + + return TOOL_TITLES[name] ?? humanizeToolName(name) +} diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index b5a8701421d..f2e7f2fb477 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -7,26 +7,41 @@ import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/model import type { ToolConfig } from '@/tools/types' /** - * Serialize workflow metadata for VFS meta.json + * Serialize workflow metadata for VFS meta.json. + * + * `locked` is the EFFECTIVE lock — true when the workflow is locked directly or + * sits inside a locked folder. A locked workflow cannot be edited, moved, + * renamed, or deleted (mutations are rejected server-side with a 423). The + * mothership should read this before attempting any workflow mutation. + * `inheritedFolderLock` carries the resolved containing-folder lock (the + * caller computes folder inheritance; see workspace-vfs materializeWorkflows). */ -export function serializeWorkflowMeta(wf: { - id: string - name: string - description?: string | null - folderId?: string | null - isDeployed: boolean - deployedAt?: Date | null - runCount: number - lastRunAt?: Date | null - createdAt: Date - updatedAt: Date -}): string { +export function serializeWorkflowMeta( + wf: { + id: string + name: string + description?: string | null + folderId?: string | null + isDeployed: boolean + deployedAt?: Date | null + runCount: number + lastRunAt?: Date | null + createdAt: Date + updatedAt: Date + locked?: boolean + }, + options?: { inheritedFolderLock?: boolean } +): string { + const directLock = wf.locked ?? false + const locked = directLock || (options?.inheritedFolderLock ?? false) return JSON.stringify( { id: wf.id, name: wf.name, description: wf.description || undefined, folderId: wf.folderId || undefined, + locked, + lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined, isDeployed: wf.isDeployed, deployedAt: wf.deployedAt?.toISOString(), runCount: wf.runCount, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 3b289b41081..0038755e603 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1016,6 +1016,37 @@ export class WorkspaceVFS { return buildVfsFolderPathMap(folders) } + /** + * Resolve the set of folder IDs that are effectively locked — locked directly + * or via a locked ancestor folder. A workflow inside any of these folders is + * itself immutable, so its meta.json must report `locked: true`. Mirrors the + * folder-chain walk in `@sim/workflow-authz` getFolderLockStatus, but resolves + * the whole workspace in memory to avoid a per-workflow DB round trip. + */ + private computeLockedFolderIds( + folders: Array<{ folderId: string; parentId: string | null; locked: boolean }> + ): Set { + const byId = new Map(folders.map((f) => [f.folderId, f])) + const lockedFolderIds = new Set() + + for (const folder of folders) { + let current: string | null = folder.folderId + const visited = new Set() + while (current && !visited.has(current)) { + visited.add(current) + const node = byId.get(current) + if (!node) break + if (node.locked) { + lockedFolderIds.add(folder.folderId) + break + } + current = node.parentId + } + } + + return lockedFolderIds + } + /** * Materialize all workflows using the shared listWorkflows function. * Workflows are nested under their folder paths in the VFS: @@ -1031,6 +1062,7 @@ export class WorkspaceVFS { ]) const folderPaths = this.buildFolderPaths(folderRows) + const lockedFolderIds = this.computeLockedFolderIds(folderRows) // NOTE: materialization is a pure READ. Alias backing (changelog/plan // folders + files) is ensured at write time — workflow create/rename @@ -1056,7 +1088,8 @@ export class WorkspaceVFS { const prefix = `${canonicalWorkflowVfsDir({ name: wf.name, folderPath })}/` const workflowPath = prefix.replace(/\/$/, '') - this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf)) + const inheritedFolderLock = wf.folderId ? lockedFolderIds.has(wf.folderId) : false + this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf, { inheritedFolderLock })) if (workflowArtifactsEnabled) { const changelog = findWorkspaceFileRecord( diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 219c194e080..2f20e012a99 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -608,6 +608,7 @@ export async function listFolders(workspaceId: string) { folderName: workflowFolder.name, parentId: workflowFolder.parentId, sortOrder: workflowFolder.sortOrder, + locked: workflowFolder.locked, }) .from(workflowFolder) .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt)))