From bcb3c2bdd41ab53a27fefb7ecda0b8db859c8a91 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 2 Jul 2026 22:47:39 -0700 Subject: [PATCH 01/13] feat(scout): add scout agent --- .../[workspaceId]/home/components/message-content/utils.ts | 1 + apps/sim/app/workspace/[workspaceId]/home/types.ts | 1 + apps/sim/lib/copilot/tools/tool-display.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 6a06125c07a..db8c8d63729 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -57,6 +57,7 @@ const TOOL_ICONS: Record = { agent: AgentIcon, custom_tool: Wrench, research: Search, + scout: Search, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 20bc2513dab..1cfc7508301 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -157,6 +157,7 @@ export const SUBAGENT_LABELS: Record = { knowledge: 'Knowledge Agent', table: 'Table Agent', custom_tool: 'Custom Tool Agent', + scout: 'Scout Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5ee25a69959..cae40ef12e0 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -97,6 +97,7 @@ const TOOL_TITLES: Record = { scheduled_task: 'Scheduled Task Agent', agent: 'Tools Agent', research: 'Research Agent', + scout: 'Scout Agent', media: 'Media Agent', superagent: 'Executing action', } From bffdeec6179e9772683f9df73bd3bce18471b0f6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 2 Jul 2026 22:48:09 -0700 Subject: [PATCH 02/13] fix(contracts): update contracts to include scout agent --- .../lib/copilot/generated/tool-catalog-v1.ts | 23 ++ .../lib/copilot/generated/tool-schemas-v1.ts | 204 ++++++++++-------- 2 files changed, 132 insertions(+), 95 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 4a4c24a1594..3c29df37192 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -85,6 +85,7 @@ export interface ToolCatalogEntry { | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' + | 'scout' | 'scrape_page' | 'search_documentation' | 'search_library_docs' @@ -183,6 +184,7 @@ export interface ToolCatalogEntry { | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' + | 'scout' | 'scrape_page' | 'search_documentation' | 'search_library_docs' @@ -214,6 +216,7 @@ export interface ToolCatalogEntry { | 'research' | 'run' | 'scheduled_task' + | 'scout' | 'superagent' | 'table' | 'workflow' @@ -3434,6 +3437,25 @@ export const ScheduledTask: ToolCatalogEntry = { internal: true, } +export const Scout: ToolCatalogEntry = { + id: 'scout', + name: 'scout', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the scout has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'compute how many rows in the pasted CSV have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'scout', +} + export const ScrapePage: ToolCatalogEntry = { id: 'scrape_page', name: 'scrape_page', @@ -4643,6 +4665,7 @@ export const TOOL_CATALOG: Record = { [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, + [Scout.id]: Scout, [ScrapePage.id]: ScrapePage, [SearchDocumentation.id]: SearchDocumentation, [SearchLibraryDocs.id]: SearchLibraryDocs, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index dcaea0db6ea..9bc03305be1 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,7 +313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -686,7 +686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -710,7 +710,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -759,7 +759,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -791,7 +791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -830,7 +830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -874,7 +874,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1206,7 +1206,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1224,7 +1224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1376,7 +1376,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1504,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1671,7 +1671,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1692,7 +1692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1714,7 +1714,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1727,7 +1727,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1740,7 +1740,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1768,14 +1768,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1800,7 +1800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1819,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1832,7 +1832,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1851,7 +1851,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1912,7 +1912,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2105,7 +2105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -2117,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2131,14 +2131,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2170,7 +2170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2187,7 +2187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2216,7 +2216,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2296,7 +2296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { @@ -2335,7 +2335,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2387,7 +2387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2462,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2519,7 +2519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2532,7 +2532,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -2553,7 +2553,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -2571,7 +2571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -2591,7 +2591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -2605,7 +2605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2619,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2653,7 +2653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2672,7 +2672,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2783,7 +2783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -2810,7 +2810,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2889,7 +2889,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2925,7 +2925,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -2942,7 +2942,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -2959,7 +2959,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - research: { + ['research']: { parameters: { properties: { topic: { @@ -2972,7 +2972,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -2995,7 +2995,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3013,7 +3013,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3030,7 +3030,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3062,7 +3062,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3094,7 +3094,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3132,7 +3132,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3175,7 +3175,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3188,7 +3188,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scout']: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the scout has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'compute how many rows in the pasted CSV have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3209,7 +3223,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3226,7 +3240,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3247,7 +3261,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3287,7 +3301,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3309,7 +3323,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3331,7 +3345,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3365,7 +3379,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3406,7 +3420,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -3420,7 +3434,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3433,7 +3447,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3462,7 +3476,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3480,7 +3494,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3505,7 +3519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { + ['user_memory']: { parameters: { type: 'object', properties: { @@ -3554,7 +3568,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -3917,7 +3931,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { @@ -3930,7 +3944,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { From 3e43b48cf3da610eea23fb556c67024b53253161 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 3 Jul 2026 11:28:38 -0700 Subject: [PATCH 03/13] feat(copilot): search agent (research+scout merge) + read-only table/KB tool handlers Mirrors mothership dev f90f9b05: - regenerated tool-catalog/tool-schemas mirrors (search trigger replaces research + scout; QueryUserTable / SearchKnowledgeBase entries) - queryUserTableServerTool / searchKnowledgeBaseServerTool: read-only wrappers delegating to the full user_table / knowledge_base handlers with hard operation allowlists (and outputPath export rejection on query_user_table) - display maps: 'search' agent label/title/icon added; research + scout entries retained so historical transcripts keep rendering - Search.id replaces Research.id in LONG_RUNNING_TOOL_IDS (it inherits research's long crawls) Co-Authored-By: Claude Fable 5 --- .../home/components/message-content/utils.ts | 3 + .../app/workspace/[workspaceId]/home/types.ts | 1 + .../lib/copilot/generated/tool-catalog-v1.ts | 195 ++++++++++++++---- .../lib/copilot/generated/tool-schemas-v1.ts | 153 +++++++++++--- .../sim/lib/copilot/request/tools/executor.ts | 4 +- .../server/knowledge/search-knowledge-base.ts | 39 ++++ apps/sim/lib/copilot/tools/server/router.ts | 4 + .../tools/server/table/query-user-table.ts | 51 +++++ apps/sim/lib/copilot/tools/tool-display.ts | 3 + 9 files changed, 383 insertions(+), 70 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/query-user-table.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index db8c8d63729..ff8126a2285 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -51,13 +51,16 @@ const TOOL_ICONS: Record = { auth: Integration, knowledge: Database, knowledge_base: Database, + search_knowledge_base: Database, table: TableIcon, + query_user_table: TableIcon, scheduled_task: Calendar, job: Calendar, agent: AgentIcon, custom_tool: Wrench, research: Search, scout: Search, + search: Search, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 1cfc7508301..2fb75a58b94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -158,6 +158,7 @@ export const SUBAGENT_LABELS: Record = { table: 'Table Agent', custom_tool: 'Custom Tool Agent', scout: 'Scout Agent', + search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3c29df37192..87da859e8ea 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -71,12 +71,12 @@ export interface ToolCatalogEntry { | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' | 'rename_file' | 'rename_file_folder' | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' @@ -85,9 +85,10 @@ export interface ToolCatalogEntry { | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' - | 'scout' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -170,12 +171,12 @@ export interface ToolCatalogEntry { | 'open_resource' | 'promote_to_live' | 'query_logs' + | 'query_user_table' | 'read' | 'redeploy' | 'rename_file' | 'rename_file_folder' | 'rename_workflow' - | 'research' | 'respond' | 'restore_resource' | 'run' @@ -184,9 +185,10 @@ export interface ToolCatalogEntry { | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' - | 'scout' | 'scrape_page' + | 'search' | 'search_documentation' + | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' @@ -213,10 +215,9 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' - | 'research' | 'run' | 'scheduled_task' - | 'scout' + | 'search' | 'superagent' | 'table' | 'workflow' @@ -3018,6 +3019,55 @@ export const QueryLogs: ToolCatalogEntry = { }, } +export const QueryUserTable: ToolCatalogEntry = { + id: 'query_user_table', + name: 'query_user_table', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3185,20 +3235,6 @@ export const RenameWorkflow: ToolCatalogEntry = { requiredPermission: 'write', } -export const Research: ToolCatalogEntry = { - id: 'research', - name: 'research', - route: 'subagent', - mode: 'async', - parameters: { - properties: { topic: { description: 'The topic to research.', type: 'string' } }, - required: ['topic'], - type: 'object', - }, - subagentId: 'research', - internal: true, -} - export const Respond: ToolCatalogEntry = { id: 'respond', name: 'respond', @@ -3437,25 +3473,6 @@ export const ScheduledTask: ToolCatalogEntry = { internal: true, } -export const Scout: ToolCatalogEntry = { - id: 'scout', - name: 'scout', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - task: { - description: - "One short scoping sentence — the scout has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'compute how many rows in the pasted CSV have invalid emails'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - subagentId: 'scout', -} - export const ScrapePage: ToolCatalogEntry = { id: 'scrape_page', name: 'scrape_page', @@ -3478,6 +3495,26 @@ export const ScrapePage: ToolCatalogEntry = { }, } +export const Search: ToolCatalogEntry = { + id: 'search', + name: 'search', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'search', + internal: true, +} + export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3493,6 +3530,49 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } +export const SearchKnowledgeBase: ToolCatalogEntry = { + id: 'search_knowledge_base', + name: 'search_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -4483,6 +4563,38 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const +export const QueryUserTableOperation = { + get: 'get', + getSchema: 'get_schema', + getRow: 'get_row', + queryRows: 'query_rows', +} as const + +export type QueryUserTableOperation = + (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] + +export const QueryUserTableOperationValues = [ + QueryUserTableOperation.get, + QueryUserTableOperation.getSchema, + QueryUserTableOperation.getRow, + QueryUserTableOperation.queryRows, +] as const + +export const SearchKnowledgeBaseOperation = { + get: 'get', + query: 'query', + listTags: 'list_tags', +} as const + +export type SearchKnowledgeBaseOperation = + (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] + +export const SearchKnowledgeBaseOperationValues = [ + SearchKnowledgeBaseOperation.get, + SearchKnowledgeBaseOperation.query, + SearchKnowledgeBaseOperation.listTags, +] as const + export const UserMemoryOperation = { add: 'add', search: 'search', @@ -4651,12 +4763,12 @@ export const TOOL_CATALOG: Record = { [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, + [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, [RenameFile.id]: RenameFile, [RenameFileFolder.id]: RenameFileFolder, [RenameWorkflow.id]: RenameWorkflow, - [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, @@ -4665,9 +4777,10 @@ export const TOOL_CATALOG: Record = { [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, - [Scout.id]: Scout, [ScrapePage.id]: ScrapePage, + [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, + [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 9bc03305be1..9541a9aa749 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2783,6 +2783,68 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + ['query_user_table']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: 'MongoDB-style filter for query_rows', + }, + limit: { + type: 'number', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + }, + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', + }, + rowId: { + type: 'string', + description: 'Row ID (required for get_row)', + }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, + tableId: { + type: 'string', + description: 'Table ID (required for all operations)', + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'get_schema', 'get_row', 'query_rows'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, ['read']: { parameters: { type: 'object', @@ -2959,19 +3021,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['research']: { - parameters: { - properties: { - topic: { - description: 'The topic to research.', - type: 'string', - }, - }, - required: ['topic'], - type: 'object', - }, - resultSchema: undefined, - }, ['respond']: { parameters: { additionalProperties: true, @@ -3188,20 +3237,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['scout']: { - parameters: { - properties: { - task: { - description: - "One short scoping sentence — the scout has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'compute how many rows in the pasted CSV have invalid emails'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - resultSchema: undefined, - }, ['scrape_page']: { parameters: { type: 'object', @@ -3223,6 +3258,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + ['search']: { + parameters: { + properties: { + task: { + description: + "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, ['search_documentation']: { parameters: { type: 'object', @@ -3240,6 +3289,56 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + ['search_knowledge_base']: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + knowledgeBaseId: { + type: 'string', + description: 'Knowledge base ID (required for all operations)', + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + }, + }, + operation: { + type: 'string', + description: 'The read operation to perform', + enum: ['get', 'query', 'list_tags'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, ['search_library_docs']: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index f479d5e99b7..63ee628f9ba 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,12 +33,12 @@ import { KnowledgeBase, MaterializeFile, Media, - Research, Run, RunBlock, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, + Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -230,7 +230,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ GenerateVideo.id, Ffmpeg.id, Media.id, - Research.id, + Search.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts new file mode 100644 index 00000000000..8b22ee7d7a4 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts @@ -0,0 +1,39 @@ +import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' + +type SearchKnowledgeBaseArgs = { + operation: string + args?: Record +} + +type SearchKnowledgeBaseResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) + +/** + * Read-only variant of knowledge_base for info-gathering agents. Copilot + * access control is a per-agent tool allowlist, so read-only access gets its + * own tool name with its own operation contract — enforced here (where + * execution happens) on top of the fail-fast guard in the Go executor. + */ +export const searchKnowledgeBaseServerTool: BaseServerTool< + SearchKnowledgeBaseArgs, + SearchKnowledgeBaseResult +> = { + name: SearchKnowledgeBase.id, + async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`, + } + } + return knowledgeBaseServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7780f97eb73..74824b53f25 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -49,10 +49,12 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' @@ -150,8 +152,10 @@ const baseServerToolRegistry: Record = { [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, + [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, + [queryUserTableServerTool.name]: queryUserTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts new file mode 100644 index 00000000000..e2b07176da5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts @@ -0,0 +1,51 @@ +import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type QueryUserTableArgs = { + operation: string + args?: Record +} + +type QueryUserTableResult = { + success: boolean + message: string + data?: any +} + +const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) + +/** + * Read-only variant of user_table for info-gathering agents. Copilot access + * control is a per-agent tool allowlist, so read-only access gets its own tool + * name with its own operation contract — enforced here (where execution + * happens) on top of the fail-fast guard in the Go executor. outputPath is + * rejected because query_rows exports rows to a workspace file through it. + */ +export const queryUserTableServerTool: BaseServerTool = { + name: QueryUserTable.id, + async execute(params: QueryUserTableArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!READ_OPERATIONS.has(operation)) { + return { + success: false, + message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, + } + } + if (params?.args && 'outputPath' in params.args) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + if (params && 'outputPath' in (params as Record)) { + return { + success: false, + message: + 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index cae40ef12e0..48df84cec5b 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -76,11 +76,13 @@ const TOOL_TITLES: Record = { search_library_docs: 'Searching library docs', user_memory: 'Accessing memory', user_table: 'Managing table', + query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', knowledge_base: 'Managing knowledge base', + search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', @@ -98,6 +100,7 @@ const TOOL_TITLES: Record = { agent: 'Tools Agent', research: 'Research Agent', scout: 'Scout Agent', + search: 'Search Agent', media: 'Media Agent', superagent: 'Executing action', } From e4200afa5790943631a003c340c1ba75356b71b4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 3 Jul 2026 14:23:10 -0700 Subject: [PATCH 04/13] feat(copilot): run_code compute-only handler; docs lint fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors mothership dev db60da94: run_code is the compute-only variant of function_execute for the search agent — same sandbox and inputs, no outputs.files / outputTable, so it cannot create or overwrite workspace resources. Wrapper handler hard-rejects the write vectors and delegates to executeFunctionExecute; run_code is deliberately absent from OUTPUT_PATH_TOOLS and the table output post-processor, so the name gating blocks writes even for leaked args. Added to LONG_RUNNING_TOOL_IDS, display title/icon maps, and the regenerated catalog/schema mirrors. Also removes two ineffective biome suppression comments in the docs workflow-preview (the rule doesn't fire in the docs app config). Co-Authored-By: Claude Fable 5 --- .../workflow-preview/format-references.tsx | 2 - .../home/components/message-content/utils.ts | 1 + .../lib/copilot/generated/tool-catalog-v1.ts | 96 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 287 ++++++++++++------ .../sim/lib/copilot/request/tools/executor.ts | 2 + .../tool-executor/register-handlers.ts | 3 + .../lib/copilot/tools/handlers/run-code.ts | 31 ++ apps/sim/lib/copilot/tools/tool-display.ts | 1 + 8 files changed, 324 insertions(+), 99 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/run-code.ts diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx index 6a3c01ae21e..15bfeb5c049 100644 --- a/apps/docs/components/workflow-preview/format-references.tsx +++ b/apps/docs/components/workflow-preview/format-references.tsx @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] { const isReference = (part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}')) return isReference ? ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) : ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index ff8126a2285..350eae9d6f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -38,6 +38,7 @@ const TOOL_ICONS: Record = { manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, + run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, workspace_file: File, diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 87da859e8ea..315d3597092 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -81,6 +81,7 @@ export interface ToolCatalogEntry { | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' @@ -181,6 +182,7 @@ export interface ToolCatalogEntry { | 'restore_resource' | 'run' | 'run_block' + | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' @@ -3332,6 +3334,99 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } +export const RunCode: ToolCatalogEntry = { + id: 'run_code', + name: 'run_code', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'table_input'], +} + export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -4773,6 +4868,7 @@ export const TOOL_CATALOG: Record = { [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, + [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 9541a9aa749..1ef49528320 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - ['agent']: { + agent: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['auth']: { + auth: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['check_deployment_status']: { + check_deployment_status: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['complete_scheduled_task']: { + complete_scheduled_task: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['crawl_website']: { + crawl_website: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_file']: { + create_file: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['create_file_folder']: { + create_file_folder: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workflow']: { + create_workflow: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['create_workspace_mcp_server']: { + create_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_file']: { + delete_file: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['delete_file_folder']: { + delete_file_folder: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workflow']: { + delete_workflow: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['delete_workspace_mcp_server']: { + delete_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -313,7 +313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy']: { + deploy: { parameters: { properties: { request: { @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['deploy_api']: { + deploy_api: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_chat']: { + deploy_chat: { parameters: { type: 'object', properties: { @@ -570,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['deploy_mcp']: { + deploy_mcp: { parameters: { type: 'object', properties: { @@ -686,7 +686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - ['diff_workflows']: { + diff_workflows: { parameters: { type: 'object', properties: { @@ -710,7 +710,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['download_to_workspace_file']: { + download_to_workspace_file: { parameters: { type: 'object', properties: { @@ -759,7 +759,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['edit_content']: { + edit_content: { parameters: { type: 'object', properties: { @@ -791,7 +791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['edit_workflow']: { + edit_workflow: { parameters: { type: 'object', properties: { @@ -830,7 +830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['enrichment_run']: { + enrichment_run: { parameters: { type: 'object', properties: { @@ -874,7 +874,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ['ffmpeg']: { + ffmpeg: { parameters: { type: 'object', properties: { @@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['file']: { + file: { parameters: { properties: { prompt: { @@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['function_execute']: { + function_execute: { parameters: { type: 'object', properties: { @@ -1206,7 +1206,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_api_key']: { + generate_api_key: { parameters: { type: 'object', properties: { @@ -1224,7 +1224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_audio']: { + generate_audio: { parameters: { type: 'object', properties: { @@ -1376,7 +1376,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_image']: { + generate_image: { parameters: { type: 'object', properties: { @@ -1504,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['generate_video']: { + generate_video: { parameters: { type: 'object', properties: { @@ -1671,7 +1671,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_outputs']: { + get_block_outputs: { parameters: { type: 'object', properties: { @@ -1692,7 +1692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_block_upstream_references']: { + get_block_upstream_references: { parameters: { type: 'object', properties: { @@ -1714,7 +1714,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployed_workflow_state']: { + get_deployed_workflow_state: { parameters: { type: 'object', properties: { @@ -1727,7 +1727,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_deployment_log']: { + get_deployment_log: { parameters: { type: 'object', properties: { @@ -1740,7 +1740,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_page_contents']: { + get_page_contents: { parameters: { type: 'object', properties: { @@ -1768,14 +1768,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_platform_actions']: { + get_platform_actions: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['get_scheduled_task_logs']: { + get_scheduled_task_logs: { parameters: { type: 'object', properties: { @@ -1800,7 +1800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_workflow_data']: { + get_workflow_data: { parameters: { type: 'object', properties: { @@ -1819,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['get_workflow_run_options']: { + get_workflow_run_options: { parameters: { type: 'object', properties: { @@ -1832,7 +1832,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['glob']: { + glob: { parameters: { type: 'object', properties: { @@ -1851,7 +1851,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['grep']: { + grep: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['knowledge']: { + knowledge: { parameters: { properties: { request: { @@ -1912,7 +1912,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['knowledge_base']: { + knowledge_base: { parameters: { type: 'object', properties: { @@ -2105,7 +2105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['list_file_folders']: { + list_file_folders: { parameters: { type: 'object', properties: { @@ -2117,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_integration_tools']: { + list_integration_tools: { parameters: { properties: { integration: { @@ -2131,14 +2131,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['list_user_workspaces']: { + list_user_workspaces: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - ['list_workspace_mcp_servers']: { + list_workspace_mcp_servers: { parameters: { type: 'object', properties: { @@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['load_deployment']: { + load_deployment: { parameters: { type: 'object', properties: { @@ -2170,7 +2170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['load_integration_tool']: { + load_integration_tool: { parameters: { properties: { tool_ids: { @@ -2187,7 +2187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_credential']: { + manage_credential: { parameters: { type: 'object', properties: { @@ -2216,7 +2216,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_custom_tool']: { + manage_custom_tool: { parameters: { type: 'object', properties: { @@ -2296,7 +2296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_folder']: { + manage_folder: { parameters: { type: 'object', properties: { @@ -2335,7 +2335,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_mcp_tool']: { + manage_mcp_tool: { parameters: { type: 'object', properties: { @@ -2387,7 +2387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_scheduled_task']: { + manage_scheduled_task: { parameters: { type: 'object', properties: { @@ -2462,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['manage_skill']: { + manage_skill: { parameters: { type: 'object', properties: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['materialize_file']: { + materialize_file: { parameters: { type: 'object', properties: { @@ -2519,7 +2519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['media']: { + media: { parameters: { properties: { prompt: { @@ -2532,7 +2532,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_file']: { + move_file: { parameters: { type: 'object', properties: { @@ -2553,7 +2553,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_file_folder']: { + move_file_folder: { parameters: { type: 'object', properties: { @@ -2571,7 +2571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['move_workflow']: { + move_workflow: { parameters: { type: 'object', properties: { @@ -2591,7 +2591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_get_auth_link']: { + oauth_get_auth_link: { parameters: { type: 'object', properties: { @@ -2605,7 +2605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['oauth_request_access']: { + oauth_request_access: { parameters: { type: 'object', properties: { @@ -2619,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['open_resource']: { + open_resource: { parameters: { type: 'object', properties: { @@ -2653,7 +2653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['promote_to_live']: { + promote_to_live: { parameters: { type: 'object', properties: { @@ -2672,7 +2672,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['query_logs']: { + query_logs: { parameters: { type: 'object', properties: { @@ -2783,7 +2783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['query_user_table']: { + query_user_table: { parameters: { type: 'object', properties: { @@ -2845,7 +2845,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['read']: { + read: { parameters: { type: 'object', properties: { @@ -2872,7 +2872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['redeploy']: { + redeploy: { parameters: { type: 'object', properties: { @@ -2951,7 +2951,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - ['rename_file']: { + rename_file: { parameters: { type: 'object', properties: { @@ -2987,7 +2987,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['rename_file_folder']: { + rename_file_folder: { parameters: { type: 'object', properties: { @@ -3004,7 +3004,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['rename_workflow']: { + rename_workflow: { parameters: { type: 'object', properties: { @@ -3021,7 +3021,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['respond']: { + respond: { parameters: { additionalProperties: true, properties: { @@ -3044,7 +3044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['restore_resource']: { + restore_resource: { parameters: { type: 'object', properties: { @@ -3062,7 +3062,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run']: { + run: { parameters: { properties: { context: { @@ -3079,7 +3079,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_block']: { + run_block: { parameters: { type: 'object', properties: { @@ -3111,7 +3111,100 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_from_block']: { + run_code: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, + run_from_block: { parameters: { type: 'object', properties: { @@ -3143,7 +3236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow']: { + run_workflow: { parameters: { type: 'object', properties: { @@ -3181,7 +3274,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['run_workflow_until_block']: { + run_workflow_until_block: { parameters: { type: 'object', properties: { @@ -3224,7 +3317,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['scheduled_task']: { + scheduled_task: { parameters: { properties: { request: { @@ -3237,7 +3330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['scrape_page']: { + scrape_page: { parameters: { type: 'object', properties: { @@ -3258,7 +3351,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search']: { + search: { parameters: { properties: { task: { @@ -3272,7 +3365,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_documentation']: { + search_documentation: { parameters: { type: 'object', properties: { @@ -3289,7 +3382,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_knowledge_base']: { + search_knowledge_base: { parameters: { type: 'object', properties: { @@ -3339,7 +3432,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['search_library_docs']: { + search_library_docs: { parameters: { type: 'object', properties: { @@ -3360,7 +3453,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_online']: { + search_online: { parameters: { type: 'object', properties: { @@ -3400,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['search_patterns']: { + search_patterns: { parameters: { type: 'object', properties: { @@ -3422,7 +3515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_block_enabled']: { + set_block_enabled: { parameters: { type: 'object', properties: { @@ -3444,7 +3537,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_environment_variables']: { + set_environment_variables: { parameters: { type: 'object', properties: { @@ -3478,7 +3571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['set_global_workflow_variables']: { + set_global_workflow_variables: { parameters: { type: 'object', properties: { @@ -3519,7 +3612,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['superagent']: { + superagent: { parameters: { properties: { task: { @@ -3533,7 +3626,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['table']: { + table: { parameters: { properties: { request: { @@ -3546,7 +3639,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_deployment_version']: { + update_deployment_version: { parameters: { type: 'object', properties: { @@ -3575,7 +3668,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_scheduled_task_history']: { + update_scheduled_task_history: { parameters: { type: 'object', properties: { @@ -3593,7 +3686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['update_workspace_mcp_server']: { + update_workspace_mcp_server: { parameters: { type: 'object', properties: { @@ -3618,7 +3711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_memory']: { + user_memory: { parameters: { type: 'object', properties: { @@ -3667,7 +3760,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['user_table']: { + user_table: { parameters: { type: 'object', properties: { @@ -4030,7 +4123,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - ['workflow']: { + workflow: { parameters: { properties: { prompt: { @@ -4043,7 +4136,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - ['workspace_file']: { + workspace_file: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 63ee628f9ba..0b6e28b28a7 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -35,6 +35,7 @@ import { Media, Run, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -225,6 +226,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunWorkflow.id, RunWorkflowUntilBlock.id, FunctionExecute.id, + RunCode.id, GenerateImage.id, GenerateAudio.id, GenerateVideo.id, diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index b39027e3526..f8f44265e5b 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -42,6 +42,7 @@ import { RenameWorkflow, RestoreResource, RunBlock, + RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -87,6 +88,7 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' +import { executeRunCode } from '../tools/handlers/run-code' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' import { executeCreateWorkflow, @@ -188,6 +190,7 @@ function buildHandlerMap(): Record { [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), + [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts new file mode 100644 index 00000000000..e27babc4512 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -0,0 +1,31 @@ +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' + +/** + * Compute-only variant of function_execute for info-gathering agents: same + * sandbox and inputs, but it must never create or overwrite workspace + * resources. The write vectors (outputs.files, outputTable) are rejected here + * on top of the Go executor's fail-fast guard; run_code is also absent from + * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a + * leaked arg could not write anything. + */ +export async function executeRunCode( + params: Record, + context: ToolExecutionContext +): Promise { + if ('outputs' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', + } + } + if ('outputTable' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + return executeFunctionExecute(params, context) +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 48df84cec5b..18abebe6198 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -76,6 +76,7 @@ const TOOL_TITLES: Record = { search_library_docs: 'Searching library docs', user_memory: 'Accessing memory', user_table: 'Managing table', + run_code: 'Running code', query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', From f104cff4101183d1ee07cef0e75daf9f9b33a1df Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 3 Jul 2026 14:34:04 -0700 Subject: [PATCH 05/13] fix(copilot): failed tool calls must surface their error in terminal data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed handler result that carried a defined-but-empty output (the app-tool executor's 'Tool not found' ships output: {}) won the priority race in getToolCallTerminalData, so the resume payload's data — the only thing the model reads — was a bare {} with the error text dropped. The search agent retried run_code 20+ times blind against a stale server because every failure rendered as empty instead of 'Tool not found'. Failed calls now always carry error in their terminal data: merged into object outputs, wrapped alongside non-object outputs, preserved when the output already has an error field. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/request/tool-call-state.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index 70433429043..d4bf91e02e0 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -60,14 +60,30 @@ export function getToolCallTerminalData( toolCall: Pick ): unknown { const output = getToolCallStateOutput(toolCall) + const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus) + if (output !== undefined) { - return output + if (!failed) { + return output + } + /** + * A failed call must always surface its error in the terminal data — this + * is what the model reads on resume. Handlers can fail with an + * empty-but-defined output (the app-tool executor's "Tool not found" ships + * `output: {}`), and preferring that output rendered failures as bare `{}`, + * so the model retried blind instead of reacting to the error. + */ + const error = + typeof toolCall.error === 'string' && toolCall.error.length > 0 + ? toolCall.error + : 'Tool failed without an error message' + if (output && typeof output === 'object' && !Array.isArray(output)) { + return 'error' in output ? output : { ...output, error } + } + return { output, error } } - if ( - toolCall.status === MothershipStreamV1ToolOutcome.success || - toolCall.status === MothershipStreamV1ToolOutcome.skipped - ) { + if (!failed) { return undefined } From b3473aa5712293623ee19820a4851ff54547a5ff Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 3 Jul 2026 17:50:45 -0700 Subject: [PATCH 06/13] feat(chat): render inline question tags from the agent in chat --- .../message-actions/message-actions.tsx | 2 +- .../message-content/components/index.ts | 1 + .../components/question/index.ts | 1 + .../components/question/question.test.ts | 39 +++ .../components/question/question.tsx | 226 ++++++++++++++++++ .../components/special-tags/index.ts | 6 + .../special-tags/special-tags.test.ts | 126 ++++++++++ .../components/special-tags/special-tags.tsx | 80 ++++++- packages/emcn/src/icons/chevron-left.tsx | 28 +++ packages/emcn/src/icons/chevron-right.tsx | 28 +++ packages/emcn/src/icons/index.ts | 2 + 11 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts create mode 100644 packages/emcn/src/icons/chevron-left.tsx create mode 100644 packages/emcn/src/icons/chevron-right.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 91ebc37ea42..2d153a83769 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -22,7 +22,7 @@ import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file' +const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' function toPlainText(raw: string): string { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index f211a5f42e5..8151e32f11c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' +export { QuestionDisplay } from './question' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts new file mode 100644 index 00000000000..2799283926c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts @@ -0,0 +1 @@ +export { formatQuestionAnswerMessage, QuestionDisplay } from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts new file mode 100644 index 00000000000..ae01308cd12 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { formatQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const QUESTIONS: QuestionItem[] = [ + { + type: 'single_select', + prompt: 'How should I handle the duplicates?', + options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], + }, + { + type: 'confirm', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], + }, + { type: 'text', prompt: 'What time zone should the daily report run in?' }, +] + +describe('formatQuestionAnswerMessage', () => { + it('sends just the answer for a single question', () => { + expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( + 'Keep the newest entry' + ) + }) + + it('sends one prompt-answer line per question for multi-step batches', () => { + expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe( + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'Delete 4 archived workflows? — Cancel\n' + + 'What time zone should the daily report run in? — EST' + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx new file mode 100644 index 00000000000..76b6d972359 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -0,0 +1,226 @@ +'use client' + +import { useState } from 'react' +import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' +import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +/** + * Builds the single user message sent after the final question is answered. + * A lone question sends just the answer text; a multi-step batch sends one + * `Prompt — Answer` line per question. + */ +export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { + if (questions.length === 1) return answers[0] + return questions.map((q, i) => `${q.prompt} — ${answers[i]}`).join('\n') +} + +/** + * The free-text input's initial value when (re)visiting a question: restore a + * previously typed answer, but not one that matches an option row (that row is + * highlighted instead). + */ +function freeTextPrefillFor(question: QuestionItem, answer: string | null): string { + if (!answer) return '' + if (question.type === 'text') return answer + return question.options?.some((o) => o.label === answer) ? '' : answer +} + +const OPTION_ROW_CLASSES = + 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' + +/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ +const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' + +/** Leading number slot matching the suggested follow-ups rows. */ +function RowNumber({ value }: { value: number }) { + return ( +
+ {value} +
+ ) +} + +type QuestionPhase = 'active' | 'answered' | 'dismissed' + +interface QuestionDisplayProps { + data: QuestionItem[] + /** Sends the combined answer as a user message; undefined renders the div inert. */ + onSelect?: (message: string) => void +} + +/** + * Inline renderer for the `` special tag: a chat-inline div with the + * user input's chrome, the current question's prompt at the top left, dismiss + * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and + * suggested-action option rows beneath. `single_select` always appends a + * free-text "Something else" row; `text` renders only the free-text row. + * Answers collect locally; answering the last question sends one combined + * user message and collapses the div to a question/answer recap. + */ +export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) { + const disabled = !onSelect + const [phase, setPhase] = useState('active') + const [step, setStep] = useState(0) + const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null)) + const [freeText, setFreeText] = useState('') + + if (data.length === 0 || phase === 'dismissed') return null + + const containerClasses = + 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' + + if (phase === 'answered') { + return ( +
+ {data.map((question, i) => ( +
+

{question.prompt}

+

{answers[i]}

+
+ ))} +
+ ) + } + + const question = data[step] + const isLast = step === data.length - 1 + const options = question.type === 'text' ? [] : (question.options ?? []) + const hasFreeText = question.type !== 'confirm' + + const goToStep = (next: number) => { + setStep(next) + setFreeText(freeTextPrefillFor(data[next], answers[next])) + } + + const handleAnswer = (answer: string) => { + const next = [...answers] + next[step] = answer + setAnswers(next) + if (!isLast) { + goToStep(step + 1) + return + } + setPhase('answered') + onSelect?.( + formatQuestionAnswerMessage( + data, + next.map((a) => a ?? '') + ) + ) + } + + const canSubmitFreeText = !disabled && freeText.trim().length > 0 + + return ( +
+
+

+ {question.prompt} +

+
+ {data.length > 1 && ( +
+ + + {step + 1} of {data.length} + + +
+ )} + {!disabled && ( + + )} +
+
+
+ {options.map((option, i) => ( + + ))} + {hasFreeText && ( +
0 && 'border-t')}> + + setFreeText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && canSubmitFreeText) { + e.preventDefault() + handleAnswer(freeText.trim()) + } + }} + placeholder={question.type === 'text' ? 'Type an answer' : 'Something else'} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' + /> + +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index c45b9199c31..cfe2573f6e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -6,6 +6,10 @@ export type { MothershipErrorTagData, OptionsTagData, ParsedSpecialContent, + QuestionItem, + QuestionOption, + QuestionTagData, + QuestionType, RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, @@ -17,9 +21,11 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, + parseQuestionTagBody, parseSpecialTags, parseTagAttributes, parseTextTagBody, + QUESTION_TYPES, SpecialTags, USAGE_UPGRADE_ACTIONS, WORKSPACE_RESOURCE_TAG_TYPES, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts new file mode 100644 index 00000000000..8b55c47139d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + parseQuestionTagBody, + parseSpecialTags, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +const SINGLE_SELECT = { + type: 'single_select', + prompt: 'How should I handle the duplicate emails?', + options: [ + { id: 'keep_newest', label: 'Keep the newest entry' }, + { id: 'merge', label: 'Merge fields into one row' }, + ], +} + +const CONFIRM = { + type: 'confirm', + prompt: 'Delete 4 archived workflows?', + options: [ + { id: 'yes', label: 'Delete them' }, + { id: 'no', label: 'Cancel' }, + ], +} + +const TEXT = { type: 'text', prompt: 'What time zone should the daily report run in?' } + +describe('parseQuestionTagBody', () => { + it('normalizes a single object body to a one-element array', () => { + expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT]) + }) + + it('preserves array order for multi-step bodies', () => { + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, CONFIRM, TEXT])) + expect(parsed).toEqual([SINGLE_SELECT, CONFIRM, TEXT]) + }) + + it('accepts text questions without options', () => { + expect(parseQuestionTagBody(JSON.stringify(TEXT))).toEqual([TEXT]) + }) + + it('rejects single_select without options', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe( + null + ) + }) + + it('rejects confirm with empty options', () => { + expect( + parseQuestionTagBody(JSON.stringify({ type: 'confirm', prompt: 'Sure?', options: [] })) + ).toBe(null) + }) + + it('rejects an unknown question type', () => { + expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, type: 'multi_select' }))).toBe( + null + ) + }) + + it('rejects an empty prompt', () => { + expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null) + }) + + it('rejects a malformed option', () => { + expect( + parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] })) + ).toBe(null) + }) + + it('rejects an array containing one invalid question', () => { + expect(parseQuestionTagBody(JSON.stringify([TEXT, { type: 'text' }]))).toBe(null) + }) + + it('rejects empty arrays and non-JSON bodies', () => { + expect(parseQuestionTagBody('[]')).toBe(null) + expect(parseQuestionTagBody('not json')).toBe(null) + }) +}) + +describe('parseSpecialTags with ', () => { + it('extracts a complete question tag interleaved with text', () => { + const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.` + const { segments, hasPendingTag } = parseSpecialTags(content, false) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before the tag. ' }, + { type: 'question', data: [SINGLE_SELECT] }, + { type: 'text', content: ' After the tag.' }, + ]) + }) + + it('extracts a multi-step array body as one segment', () => { + const content = `${JSON.stringify([SINGLE_SELECT, CONFIRM, TEXT])}` + const { segments } = parseSpecialTags(content, false) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, CONFIRM, TEXT] }]) + }) + + it('flags an unclosed question tag as pending while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags( + 'Thinking about it. [{"type":"single_sel', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) + }) + + it('strips a trailing partial opening tag while streaming', () => { + const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { + const { segments, hasPendingTag } = parseSpecialTags( + 'Before. {"type":"single_select"} After.', + false + ) + expect(hasPendingTag).toBe(false) + expect(segments).toEqual([ + { type: 'text', content: 'Before. ' }, + { type: 'text', content: ' After.' }, + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index e8de183ad03..ace7a804504 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -17,6 +17,7 @@ import { useParams } from 'next/navigation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' +import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' import type { ChatMessageContext, MothershipResource, @@ -94,6 +95,29 @@ export interface FileTagData { content: string } +export const QUESTION_TYPES = ['single_select', 'confirm', 'text'] as const + +export type QuestionType = (typeof QUESTION_TYPES)[number] + +export interface QuestionOption { + id: string + label: string +} + +/** + * One question in a `` tag. `options` is required for + * `single_select` and `confirm`; `text` questions render only a free-text + * input. `single_select` always additionally offers a free-text "Other" row. + */ +export interface QuestionItem { + type: QuestionType + prompt: string + options?: QuestionOption[] +} + +/** Normalized `` payload: single-object bodies become a one-element array. */ +export type QuestionTagData = QuestionItem[] + export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] @@ -113,6 +137,7 @@ export type ContentSegment = | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -121,6 +146,7 @@ export type RuntimeSpecialTagName = | 'mothership-error' | 'file' | 'workspace_resource' + | 'question' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -134,6 +160,7 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'mothership-error', 'file', 'workspace_resource', + 'question', ] as const const SPECIAL_TAG_NAMES = [ @@ -143,6 +170,7 @@ const SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'workspace_resource', + 'question', ] as const function isRecord(value: unknown): value is Record { @@ -220,6 +248,46 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT return id.length > 0 } +function isQuestionOption(value: unknown): value is QuestionOption { + if (!isRecord(value)) return false + return typeof value.id === 'string' && typeof value.label === 'string' +} + +function isQuestionItem(value: unknown): value is QuestionItem { + if (!isRecord(value)) return false + if ( + typeof value.type !== 'string' || + !(QUESTION_TYPES as readonly string[]).includes(value.type) + ) { + return false + } + if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false + // text questions carry no options; select/confirm require at least one. + if (value.type === 'text') return true + return ( + Array.isArray(value.options) && + value.options.length > 0 && + value.options.every(isQuestionOption) + ) +} + +/** + * Parses a `` tag body. Accepts a single question object or a + * non-empty array of them; single objects are normalized to a one-element + * array so the renderer only handles the array shape. + */ +export function parseQuestionTagBody(body: string): QuestionTagData | null { + try { + const parsed = JSON.parse(body) as unknown + if (Array.isArray(parsed)) { + return parsed.length > 0 && parsed.every(isQuestionItem) ? parsed : null + } + return isQuestionItem(parsed) ? [parsed] : null + } catch { + return null + } +} + export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -268,6 +336,7 @@ function parseSpecialTagData( | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } + | { type: 'question'; data: QuestionTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -299,6 +368,11 @@ function parseSpecialTagData( return data ? { type: 'workspace_resource', data } : null } + if (tagName === 'question') { + const data = parseQuestionTagBody(body) + return data ? { type: 'question', data } : null + } + return null } @@ -424,6 +498,8 @@ export function SpecialTags({ return case 'workspace_resource': return + case 'question': + return default: return null } @@ -760,7 +836,7 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { href={data.value} target='_blank' rel='noopener noreferrer' - className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]' + className='flex items-center gap-2 rounded-2xl border border-[var(--border-1)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]' > {createElement(Icon, { className: 'size-[16px] shrink-0' })} Connect {data.provider} @@ -788,7 +864,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit' return ( -
+
) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx new file mode 100644 index 00000000000..2f2a591ae87 --- /dev/null +++ b/packages/emcn/src/icons/chevron-right.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * ChevronRight icon component + * @param props - SVG properties including className, fill, etc. + */ +export function ChevronRight(props: SVGProps) { + return ( + + ) +} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 1af0459200b..9879a0c84e5 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -15,6 +15,8 @@ export { Calendar } from './calendar' export { Card } from './card' export { Check } from './check' export { ChevronDown } from './chevron-down' +export { ChevronLeft } from './chevron-left' +export { ChevronRight } from './chevron-right' export { CircleAlert } from './circle-alert' export { CircleCheck } from './circle-check' export { CircleInfo } from './circle-info' From eb88ffaf4154708cfe7ab8bfde2eb73c690eb229 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 3 Jul 2026 17:54:20 -0700 Subject: [PATCH 07/13] fix(chat): let inert multi-step questions browse all prompts --- .../message-content/components/question/question.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 76b6d972359..8ca4714d2da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -140,7 +140,9 @@ export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) { type='button' variant='ghost' onClick={() => goToStep(step + 1)} - disabled={isLast || answers[step] === null} + // Inert renders (older messages) browse freely; interactive ones + // gate forward movement on the current question being answered. + disabled={isLast || (!disabled && answers[step] === null)} className={cn( ICON_BUTTON_CLASSES, 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50' From 57b853e5acb289b5d465d479803c218082738dab Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 3 Jul 2026 17:56:45 -0700 Subject: [PATCH 08/13] improvement(chat): guard question answer formatting against sparse arrays --- .../message-content/components/question/question.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 8ca4714d2da..803f7dc2a8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -10,8 +10,8 @@ import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components * `Prompt — Answer` line per question. */ export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { - if (questions.length === 1) return answers[0] - return questions.map((q, i) => `${q.prompt} — ${answers[i]}`).join('\n') + if (questions.length === 1) return answers[0] ?? '' + return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') } /** From 7fc6bb0e4a4694bdc258ae7235e019cbb0eb2f3e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 4 Jul 2026 00:13:05 -0700 Subject: [PATCH 09/13] chore(copilot): drop user_memory from generated contracts and tool display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to mothership 8ae32e97 (user_memory tool removed — the feature no longer exists). Regenerates the mothership contract mirrors via generate-mship-contracts.ts, which also picks up the pending telemetry contract additions (gen_ai.agent.name labels, llm.client.context_tokens, llm.client.compactions, llm.request.compaction_trigger, llm.compaction.pause, gen_ai.usage.context_tokens), and removes the user_memory display title. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/generated/metrics-v1.ts | 4 + .../lib/copilot/generated/tool-catalog-v1.ts | 65 ----- .../lib/copilot/generated/tool-schemas-v1.ts | 243 +++++++----------- .../copilot/generated/trace-attributes-v1.ts | 6 + apps/sim/lib/copilot/tools/tool-display.ts | 1 - 5 files changed, 107 insertions(+), 212 deletions(-) diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts index fefc4534ea3..f99d761574f 100644 --- a/apps/sim/lib/copilot/generated/metrics-v1.ts +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -28,6 +28,8 @@ export const Metric = { CopilotVfsMaterializeDuration: 'copilot.vfs.materialize.duration', GenAiClientCacheTokenUsage: 'gen_ai.client.cache.token.usage', GenAiClientTokenUsage: 'gen_ai.client.token.usage', + LlmClientCompactions: 'llm.client.compactions', + LlmClientContextTokens: 'llm.client.context_tokens', LlmClientErrors: 'llm.client.errors', LlmClientOutputCutoff: 'llm.client.output_cutoff', LlmClientStreamDuration: 'llm.client.stream.duration', @@ -53,6 +55,8 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.vfs.materialize.duration', 'gen_ai.client.cache.token.usage', 'gen_ai.client.token.usage', + 'llm.client.compactions', + 'llm.client.context_tokens', 'llm.client.errors', 'llm.client.output_cutoff', 'llm.client.stream.duration', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 315d3597092..ac259253df3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -101,7 +101,6 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -202,7 +201,6 @@ export interface ToolCatalogEntry { | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' - | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -3952,50 +3950,6 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const UserMemory: ToolCatalogEntry = { - id: 'user_memory', - name: 'user_memory', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { type: 'number', description: 'Number of results for search (default 10)' }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { type: 'string', description: 'Search query to find relevant memories' }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { type: 'string', description: 'Value to remember' }, - }, - required: ['operation'], - }, -} - export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4690,24 +4644,6 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const -export const UserMemoryOperation = { - add: 'add', - search: 'search', - delete: 'delete', - correct: 'correct', - list: 'list', -} as const - -export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] - -export const UserMemoryOperationValues = [ - UserMemoryOperation.add, - UserMemoryOperation.search, - UserMemoryOperation.delete, - UserMemoryOperation.correct, - UserMemoryOperation.list, -] as const - export const UserTableOperation = { create: 'create', createFromFile: 'create_from_file', @@ -4888,7 +4824,6 @@ export const TOOL_CATALOG: Record = { [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, - [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 1ef49528320..1a4948e59fc 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,7 +10,7 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + ['agent']: { parameters: { properties: { request: { @@ -23,7 +23,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - auth: { + ['auth']: { parameters: { properties: { request: { @@ -36,7 +36,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { + ['check_deployment_status']: { parameters: { type: 'object', properties: { @@ -48,7 +48,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { + ['complete_scheduled_task']: { parameters: { type: 'object', properties: { @@ -61,7 +61,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { + ['crawl_website']: { parameters: { type: 'object', properties: { @@ -96,7 +96,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_file: { + ['create_file']: { parameters: { type: 'object', properties: { @@ -162,7 +162,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - create_file_folder: { + ['create_file_folder']: { parameters: { type: 'object', properties: { @@ -180,7 +180,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workflow: { + ['create_workflow']: { parameters: { type: 'object', properties: { @@ -205,7 +205,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_workspace_mcp_server: { + ['create_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -238,7 +238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { + ['delete_file']: { parameters: { type: 'object', properties: { @@ -268,7 +268,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - delete_file_folder: { + ['delete_file_folder']: { parameters: { type: 'object', properties: { @@ -284,7 +284,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workflow: { + ['delete_workflow']: { parameters: { type: 'object', properties: { @@ -300,7 +300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_workspace_mcp_server: { + ['delete_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -313,7 +313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy: { + ['deploy']: { parameters: { properties: { request: { @@ -327,7 +327,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + ['deploy_api']: { parameters: { type: 'object', properties: { @@ -411,7 +411,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + ['deploy_chat']: { parameters: { type: 'object', properties: { @@ -570,7 +570,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_mcp: { + ['deploy_mcp']: { parameters: { type: 'object', properties: { @@ -686,7 +686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, - diff_workflows: { + ['diff_workflows']: { parameters: { type: 'object', properties: { @@ -710,7 +710,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + ['download_to_workspace_file']: { parameters: { type: 'object', properties: { @@ -759,7 +759,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { + ['edit_content']: { parameters: { type: 'object', properties: { @@ -791,7 +791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - edit_workflow: { + ['edit_workflow']: { parameters: { type: 'object', properties: { @@ -830,7 +830,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + ['enrichment_run']: { parameters: { type: 'object', properties: { @@ -874,7 +874,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['matched', 'result'], }, }, - ffmpeg: { + ['ffmpeg']: { parameters: { type: 'object', properties: { @@ -1055,7 +1055,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { + ['file']: { parameters: { properties: { prompt: { @@ -1068,7 +1068,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { + ['function_execute']: { parameters: { type: 'object', properties: { @@ -1206,7 +1206,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_api_key: { + ['generate_api_key']: { parameters: { type: 'object', properties: { @@ -1224,7 +1224,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_audio: { + ['generate_audio']: { parameters: { type: 'object', properties: { @@ -1376,7 +1376,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + ['generate_image']: { parameters: { type: 'object', properties: { @@ -1504,7 +1504,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_video: { + ['generate_video']: { parameters: { type: 'object', properties: { @@ -1671,7 +1671,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_outputs: { + ['get_block_outputs']: { parameters: { type: 'object', properties: { @@ -1692,7 +1692,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_block_upstream_references: { + ['get_block_upstream_references']: { parameters: { type: 'object', properties: { @@ -1714,7 +1714,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployed_workflow_state: { + ['get_deployed_workflow_state']: { parameters: { type: 'object', properties: { @@ -1727,7 +1727,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + ['get_deployment_log']: { parameters: { type: 'object', properties: { @@ -1740,7 +1740,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_page_contents: { + ['get_page_contents']: { parameters: { type: 'object', properties: { @@ -1768,14 +1768,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { + ['get_platform_actions']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - get_scheduled_task_logs: { + ['get_scheduled_task_logs']: { parameters: { type: 'object', properties: { @@ -1800,7 +1800,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_data: { + ['get_workflow_data']: { parameters: { type: 'object', properties: { @@ -1819,7 +1819,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_run_options: { + ['get_workflow_run_options']: { parameters: { type: 'object', properties: { @@ -1832,7 +1832,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - glob: { + ['glob']: { parameters: { type: 'object', properties: { @@ -1851,7 +1851,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - grep: { + ['grep']: { parameters: { type: 'object', properties: { @@ -1899,7 +1899,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge: { + ['knowledge']: { parameters: { properties: { request: { @@ -1912,7 +1912,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + ['knowledge_base']: { parameters: { type: 'object', properties: { @@ -2105,7 +2105,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - list_file_folders: { + ['list_file_folders']: { parameters: { type: 'object', properties: { @@ -2117,7 +2117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integration_tools: { + ['list_integration_tools']: { parameters: { properties: { integration: { @@ -2131,14 +2131,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { + ['list_user_workspaces']: { parameters: { type: 'object', properties: {}, }, resultSchema: undefined, }, - list_workspace_mcp_servers: { + ['list_workspace_mcp_servers']: { parameters: { type: 'object', properties: { @@ -2151,7 +2151,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_deployment: { + ['load_deployment']: { parameters: { type: 'object', properties: { @@ -2170,7 +2170,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_integration_tool: { + ['load_integration_tool']: { parameters: { properties: { tool_ids: { @@ -2187,7 +2187,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_credential: { + ['manage_credential']: { parameters: { type: 'object', properties: { @@ -2216,7 +2216,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_custom_tool: { + ['manage_custom_tool']: { parameters: { type: 'object', properties: { @@ -2296,7 +2296,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { + ['manage_folder']: { parameters: { type: 'object', properties: { @@ -2335,7 +2335,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_mcp_tool: { + ['manage_mcp_tool']: { parameters: { type: 'object', properties: { @@ -2387,7 +2387,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + ['manage_scheduled_task']: { parameters: { type: 'object', properties: { @@ -2462,7 +2462,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_skill: { + ['manage_skill']: { parameters: { type: 'object', properties: { @@ -2495,7 +2495,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { + ['materialize_file']: { parameters: { type: 'object', properties: { @@ -2519,7 +2519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - media: { + ['media']: { parameters: { properties: { prompt: { @@ -2532,7 +2532,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file: { + ['move_file']: { parameters: { type: 'object', properties: { @@ -2553,7 +2553,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_file_folder: { + ['move_file_folder']: { parameters: { type: 'object', properties: { @@ -2571,7 +2571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - move_workflow: { + ['move_workflow']: { parameters: { type: 'object', properties: { @@ -2591,7 +2591,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_get_auth_link: { + ['oauth_get_auth_link']: { parameters: { type: 'object', properties: { @@ -2605,7 +2605,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - oauth_request_access: { + ['oauth_request_access']: { parameters: { type: 'object', properties: { @@ -2619,7 +2619,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - open_resource: { + ['open_resource']: { parameters: { type: 'object', properties: { @@ -2653,7 +2653,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - promote_to_live: { + ['promote_to_live']: { parameters: { type: 'object', properties: { @@ -2672,7 +2672,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_logs: { + ['query_logs']: { parameters: { type: 'object', properties: { @@ -2783,7 +2783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_user_table: { + ['query_user_table']: { parameters: { type: 'object', properties: { @@ -2845,7 +2845,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - read: { + ['read']: { parameters: { type: 'object', properties: { @@ -2872,7 +2872,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - redeploy: { + ['redeploy']: { parameters: { type: 'object', properties: { @@ -2951,7 +2951,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - rename_file: { + ['rename_file']: { parameters: { type: 'object', properties: { @@ -2987,7 +2987,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - rename_file_folder: { + ['rename_file_folder']: { parameters: { type: 'object', properties: { @@ -3004,7 +3004,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rename_workflow: { + ['rename_workflow']: { parameters: { type: 'object', properties: { @@ -3021,7 +3021,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - respond: { + ['respond']: { parameters: { additionalProperties: true, properties: { @@ -3044,7 +3044,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - restore_resource: { + ['restore_resource']: { parameters: { type: 'object', properties: { @@ -3062,7 +3062,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run: { + ['run']: { parameters: { properties: { context: { @@ -3079,7 +3079,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_block: { + ['run_block']: { parameters: { type: 'object', properties: { @@ -3111,7 +3111,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_code: { + ['run_code']: { parameters: { type: 'object', properties: { @@ -3204,7 +3204,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_from_block: { + ['run_from_block']: { parameters: { type: 'object', properties: { @@ -3236,7 +3236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow: { + ['run_workflow']: { parameters: { type: 'object', properties: { @@ -3274,7 +3274,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - run_workflow_until_block: { + ['run_workflow_until_block']: { parameters: { type: 'object', properties: { @@ -3317,7 +3317,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { + ['scheduled_task']: { parameters: { properties: { request: { @@ -3330,7 +3330,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + ['scrape_page']: { parameters: { type: 'object', properties: { @@ -3351,7 +3351,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search: { + ['search']: { parameters: { properties: { task: { @@ -3365,7 +3365,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + ['search_documentation']: { parameters: { type: 'object', properties: { @@ -3382,7 +3382,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_knowledge_base: { + ['search_knowledge_base']: { parameters: { type: 'object', properties: { @@ -3432,7 +3432,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - search_library_docs: { + ['search_library_docs']: { parameters: { type: 'object', properties: { @@ -3453,7 +3453,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + ['search_online']: { parameters: { type: 'object', properties: { @@ -3493,7 +3493,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_patterns: { + ['search_patterns']: { parameters: { type: 'object', properties: { @@ -3515,7 +3515,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_block_enabled: { + ['set_block_enabled']: { parameters: { type: 'object', properties: { @@ -3537,7 +3537,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_environment_variables: { + ['set_environment_variables']: { parameters: { type: 'object', properties: { @@ -3571,7 +3571,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - set_global_workflow_variables: { + ['set_global_workflow_variables']: { parameters: { type: 'object', properties: { @@ -3612,7 +3612,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - superagent: { + ['superagent']: { parameters: { properties: { task: { @@ -3626,7 +3626,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - table: { + ['table']: { parameters: { properties: { request: { @@ -3639,7 +3639,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_deployment_version: { + ['update_deployment_version']: { parameters: { type: 'object', properties: { @@ -3668,7 +3668,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { + ['update_scheduled_task_history']: { parameters: { type: 'object', properties: { @@ -3686,7 +3686,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_workspace_mcp_server: { + ['update_workspace_mcp_server']: { parameters: { type: 'object', properties: { @@ -3711,56 +3711,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - user_memory: { - parameters: { - type: 'object', - properties: { - confidence: { - type: 'number', - description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', - }, - correct_value: { - type: 'string', - description: - "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", - }, - key: { - type: 'string', - description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", - }, - limit: { - type: 'number', - description: 'Number of results for search (default 10)', - }, - memory_type: { - type: 'string', - description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", - enum: ['preference', 'entity', 'history', 'correction'], - }, - operation: { - type: 'string', - description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", - enum: ['add', 'search', 'delete', 'correct', 'list'], - }, - query: { - type: 'string', - description: 'Search query to find relevant memories', - }, - source: { - type: 'string', - description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", - enum: ['explicit', 'inferred'], - }, - value: { - type: 'string', - description: 'Value to remember', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, - user_table: { + ['user_table']: { parameters: { type: 'object', properties: { @@ -4123,7 +4074,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - workflow: { + ['workflow']: { parameters: { properties: { prompt: { @@ -4136,7 +4087,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workspace_file: { + ['workspace_file']: { parameters: { type: 'object', properties: { diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index c5024ee3571..4bb494c2d57 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -420,6 +420,7 @@ export const TraceAttr = { GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens', GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens', GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens', + GenAiUsageContextTokens: 'gen_ai.usage.context_tokens', GenAiUsageInputTokens: 'gen_ai.usage.input_tokens', GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens', GenAiUsageThinkingTokens: 'gen_ai.usage.thinking_tokens', @@ -446,9 +447,11 @@ export const TraceAttr = { KnowledgeBaseId: 'knowledge_base.id', KnowledgeBaseName: 'knowledge_base.name', LlmBackend: 'llm.backend', + LlmCompactionPause: 'llm.compaction.pause', LlmErrorStage: 'llm.error_stage', LlmProtocol: 'llm.protocol', LlmRequestBodyBytes: 'llm.request.body_bytes', + LlmRequestCompactionTrigger: 'llm.request.compaction_trigger', LlmStreamBytes: 'llm.stream.bytes', LlmStreamChunks: 'llm.stream.chunks', LlmStreamFirstChunkBytes: 'llm.stream.first_chunk_bytes', @@ -1036,6 +1039,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.usage.cache_creation_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_tokens', + 'gen_ai.usage.context_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.thinking_tokens', @@ -1062,9 +1066,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'knowledge_base.id', 'knowledge_base.name', 'llm.backend', + 'llm.compaction.pause', 'llm.error_stage', 'llm.protocol', 'llm.request.body_bytes', + 'llm.request.compaction_trigger', 'llm.stream.bytes', 'llm.stream.chunks', 'llm.stream.first_chunk_bytes', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 18abebe6198..f9bb09014c0 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -74,7 +74,6 @@ function workspaceFileTitle(args: ToolArgs): string { const TOOL_TITLES: Record = { read: 'Reading file', search_library_docs: 'Searching library docs', - user_memory: 'Accessing memory', user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', From e102fd5fa1a34f4b0fcce626bc6a90a68199131f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 4 Jul 2026 00:36:52 -0700 Subject: [PATCH 10/13] improvement(chat): answered question card becomes the user turn; two select types only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI ordering: answering a question card no longer echoes a duplicate user bubble. The combined answer still goes on the wire as a user message, but the chat pairs it back to its card (strict 'Prompt — Answer' match, now uniform for single questions too) and renders the card as the answered recap — the card IS the user turn, and the next assistant message streams below it. The pairing is derived from the transcript, so live and reloaded renders are identical; a dismissed card followed by an unrelated typed message does not match and renders normally. Messages ending with a question card also drop the copy/thumbs actions row — the card is an input surface, not a reactable assistant turn. Question types are now single_select and multi_select only: text is removed (the free-text 'Something else' row covers it) and confirm collapses into single_select with Yes/No options. multi_select rows toggle with a check and the free-text row's arrow submits the step; answers are comma-joined labels plus any typed entry. Agent-supplied catch-all options ('Other', 'Something else', 'None of the above') are stripped at parse — the card always provides its own free-text row; a question left with no real options is invalid. Co-Authored-By: Claude Fable 5 --- .../components/chat-content/chat-content.tsx | 4 + .../components/question/index.ts | 6 +- .../components/question/question.test.ts | 57 +++- .../components/question/question.tsx | 261 ++++++++++++------ .../components/special-tags/index.ts | 1 + .../special-tags/special-tags.test.ts | 59 +++- .../components/special-tags/special-tags.tsx | 65 ++++- .../message-content/message-content.tsx | 4 + .../mothership-chat/mothership-chat.tsx | 58 +++- 9 files changed, 393 insertions(+), 122 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index afeac25cc3b..eac918b2bbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -279,6 +279,8 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string isStreaming?: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void @@ -287,6 +289,7 @@ interface ChatContentProps { function ChatContentInner({ content, isStreaming = false, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, onRevealStateChange, @@ -406,6 +409,7 @@ function ChatContentInner({ ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts index 2799283926c..5272577cfda 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts @@ -1 +1,5 @@ -export { formatQuestionAnswerMessage, QuestionDisplay } from './question' +export { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, + QuestionDisplay, +} from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index ae01308cd12..c78caf38e0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { formatQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' +import { + formatQuestionAnswerMessage, + parseQuestionAnswerMessage, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' const QUESTIONS: QuestionItem[] = [ @@ -12,20 +15,27 @@ const QUESTIONS: QuestionItem[] = [ options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], }, { - type: 'confirm', + type: 'single_select', prompt: 'Delete 4 archived workflows?', options: [ { id: 'yes', label: 'Delete them' }, { id: 'no', label: 'Cancel' }, ], }, - { type: 'text', prompt: 'What time zone should the daily report run in?' }, + { + type: 'multi_select', + prompt: 'What time zone should the daily report run in?', + options: [ + { id: 'est', label: 'EST' }, + { id: 'pst', label: 'PST' }, + ], + }, ] describe('formatQuestionAnswerMessage', () => { - it('sends just the answer for a single question', () => { + it('sends a prompt-answer line for a single question', () => { expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( - 'Keep the newest entry' + 'How should I handle the duplicates? — Keep the newest entry' ) }) @@ -37,3 +47,40 @@ describe('formatQuestionAnswerMessage', () => { ) }) }) + +describe('parseQuestionAnswerMessage', () => { + it('round-trips what formatQuestionAnswerMessage produces', () => { + const answers = ['Keep the newest entry', 'Cancel', 'EST'] + const message = formatQuestionAnswerMessage(QUESTIONS, answers) + expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) + }) + + it('round-trips a single question', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them']) + }) + + it('rejects an unrelated user message (dismissed card, typed something else)', () => { + expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull() + }) + + it('rejects when the line count does not match the question count', () => { + const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B']) + expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull() + }) + + it('rejects when a line pairs with the wrong prompt', () => { + const swapped = + 'Delete 4 archived workflows? — Cancel\n' + + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'What time zone should the daily report run in? — EST' + expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull() + }) + + it('preserves em-dashes inside the answer text', () => { + const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups']) + expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([ + 'newest — but keep backups', + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 803f7dc2a8e..25c340ecc5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -1,28 +1,39 @@ 'use client' import { useState } from 'react' -import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' +import { ArrowRight, Button, Check, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' /** - * Builds the single user message sent after the final question is answered. - * A lone question sends just the answer text; a multi-step batch sends one - * `Prompt — Answer` line per question. + * Builds the single user message sent after the final question is answered: + * one `Prompt — Answer` line per question, for lone questions too. The uniform + * shape is what lets the chat pair this message back to its question card + * (see parseQuestionAnswerMessage) and render the card as the user turn + * instead of echoing a duplicate bubble. */ export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { - if (questions.length === 1) return answers[0] ?? '' return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') } /** - * The free-text input's initial value when (re)visiting a question: restore a - * previously typed answer, but not one that matches an option row (that row is - * highlighted instead). + * Strictly matches a user message against a question batch's answer format: + * exactly one `Prompt — Answer` line per question, in order. Returns the + * answers, or null when the message is not this batch's answer — a dismissed + * card followed by an unrelated typed message must not match. */ -function freeTextPrefillFor(question: QuestionItem, answer: string | null): string { - if (!answer) return '' - if (question.type === 'text') return answer - return question.options?.some((o) => o.label === answer) ? '' : answer +export function parseQuestionAnswerMessage( + questions: QuestionItem[], + content: string +): string[] | null { + const lines = content.split('\n') + if (lines.length !== questions.length) return null + const answers: string[] = [] + for (const [i, question] of questions.entries()) { + const prefix = `${question.prompt} — ` + if (!lines[i].startsWith(prefix)) return null + answers.push(lines[i].slice(prefix.length)) + } + return answers } const OPTION_ROW_CLASSES = @@ -44,6 +55,12 @@ type QuestionPhase = 'active' | 'answered' | 'dismissed' interface QuestionDisplayProps { data: QuestionItem[] + /** + * Answers resolved from the transcript (the paired user message that + * answered this card). When present the card renders as the answered recap + * — it IS the user turn; the paired message bubble is hidden by the chat. + */ + answers?: string[] /** Sends the combined answer as a user message; undefined renders the div inert. */ onSelect?: (message: string) => void } @@ -52,64 +69,124 @@ interface QuestionDisplayProps { * Inline renderer for the `` special tag: a chat-inline div with the * user input's chrome, the current question's prompt at the top left, dismiss * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and - * suggested-action option rows beneath. `single_select` always appends a - * free-text "Something else" row; `text` renders only the free-text row. - * Answers collect locally; answering the last question sends one combined - * user message and collapses the div to a question/answer recap. + * suggested-action option rows beneath. Both question types append a + * free-text "Something else" row. `single_select` answers and advances on + * click; `multi_select` rows toggle and the free-text row's arrow submits the + * step. Answering the last question sends one combined user message and + * collapses the div to a question/answer recap. */ -export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) { +export function QuestionDisplay({ + data, + answers: transcriptAnswers, + onSelect, +}: QuestionDisplayProps) { const disabled = !onSelect const [phase, setPhase] = useState('active') const [step, setStep] = useState(0) - const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null)) + const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) + const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) const [freeText, setFreeText] = useState('') - if (data.length === 0 || phase === 'dismissed') return null - const containerClasses = 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' - if (phase === 'answered') { + // Transcript answers win over local state: they survive reloads (local + // phase does not) and keep live + rehydrated renders identical. + const localAnswers = + phase === 'answered' + ? data.map((question, i) => + answerFor(question, selectedByStep[i] ?? [], customByStep[i] ?? '') + ) + : null + const recapAnswers = transcriptAnswers ?? localAnswers + if (data.length > 0 && recapAnswers) { return (
{data.map((question, i) => (

{question.prompt}

-

{answers[i]}

+

{recapAnswers[i]}

))}
) } + if (data.length === 0 || phase === 'dismissed') return null + const question = data[step] const isLast = step === data.length - 1 - const options = question.type === 'text' ? [] : (question.options ?? []) - const hasFreeText = question.type !== 'confirm' + const options = question.options + const selected = selectedByStep[step] ?? [] + const isMulti = question.type === 'multi_select' + + const commitCustom = (): string[] => { + const next = [...customByStep] + next[step] = freeText.trim() + setCustomByStep(next) + return next + } const goToStep = (next: number) => { + commitCustom() setStep(next) - setFreeText(freeTextPrefillFor(data[next], answers[next])) + setFreeText(customByStep[next] ?? '') } - const handleAnswer = (answer: string) => { - const next = [...answers] - next[step] = answer - setAnswers(next) + const finishStep = (selections: string[][], customs: string[]) => { if (!isLast) { - goToStep(step + 1) + setStep(step + 1) + setFreeText(customs[step + 1] ?? '') return } setPhase('answered') onSelect?.( formatQuestionAnswerMessage( data, - next.map((a) => a ?? '') + data.map((q, i) => answerFor(q, selections[i] ?? [], customs[i] ?? '')) ) ) } - const canSubmitFreeText = !disabled && freeText.trim().length > 0 + const handleSingleSelect = (label: string) => { + const selections = [...selectedByStep] + selections[step] = [label] + setSelectedByStep(selections) + const customs = [...customByStep] + customs[step] = '' + setCustomByStep(customs) + setFreeText('') + finishStep(selections, customs) + } + + const handleMultiToggle = (label: string) => { + const selections = [...selectedByStep] + const current = selections[step] ?? [] + selections[step] = current.includes(label) + ? current.filter((l) => l !== label) + : [...current, label] + setSelectedByStep(selections) + } + + const submitFreeTextRow = () => { + const customs = commitCustom() + if (isMulti) { + finishStep(selectedByStep, customs) + return + } + const selections = [...selectedByStep] + selections[step] = [] + setSelectedByStep(selections) + finishStep(selections, customs) + } + + const stepAnswered = (i: number): boolean => + (selectedByStep[i]?.length ?? 0) > 0 || + (i === step ? freeText.trim().length > 0 : (customByStep[i] ?? '').trim().length > 0) + + // single_select: the arrow submits the typed "Something else" answer. + // multi_select: the arrow submits the step (selections and/or typed text). + const canSubmitRow = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) return (
@@ -142,7 +219,7 @@ export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) { onClick={() => goToStep(step + 1)} // Inert renders (older messages) browse freely; interactive ones // gate forward movement on the current question being answered. - disabled={isLast || (!disabled && answers[step] === null)} + disabled={isLast || (!disabled && !stepAnswered(step))} className={cn( ICON_BUTTON_CLASSES, 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50' @@ -170,59 +247,81 @@ export function QuestionDisplay({ data, onSelect }: QuestionDisplayProps) {
- {options.map((option, i) => ( - - ))} - {hasFreeText && ( -
0 && 'border-t')}> - - setFreeText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmitFreeText) { - e.preventDefault() - handleAnswer(freeText.trim()) - } - }} - placeholder={question.type === 'text' ? 'Type an answer' : 'Something else'} - aria-label={question.prompt} - className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' - /> + {options.map((option, i) => { + const isSelected = selected.includes(option.label) + return ( -
- )} + ) + })} +
0 && 'border-t')}> + + setFreeText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && canSubmitRow) { + e.preventDefault() + submitFreeTextRow() + } + }} + placeholder='Something else' + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' + /> + +
) } + +/** + * A step's combined answer: selected option labels in option order, with the + * typed "Something else" entry appended last. single_select carries at most + * one selection, so this collapses to the chosen label or the typed text. + */ +function answerFor(question: QuestionItem, selected: string[], custom: string): string { + const ordered = question.options + .map((option) => option.label) + .filter((label) => selected.includes(label)) + const parts = custom.trim() ? [...ordered, custom.trim()] : ordered + return parts.join(', ') +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index cfe2573f6e8..16dbf2783e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -21,6 +21,7 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, + parseLastQuestionTag, parseQuestionTagBody, parseSpecialTags, parseTagAttributes, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 8b55c47139d..638e3c3a747 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -16,8 +16,8 @@ const SINGLE_SELECT = { ], } -const CONFIRM = { - type: 'confirm', +const YES_NO = { + type: 'single_select', prompt: 'Delete 4 archived workflows?', options: [ { id: 'yes', label: 'Delete them' }, @@ -25,7 +25,15 @@ const CONFIRM = { ], } -const TEXT = { type: 'text', prompt: 'What time zone should the daily report run in?' } +const MULTI_SELECT = { + type: 'multi_select', + prompt: 'Which channels should the report go to?', + options: [ + { id: 'slack', label: 'Slack' }, + { id: 'email', label: 'Email' }, + { id: 'sheet', label: 'Google Sheet' }, + ], +} describe('parseQuestionTagBody', () => { it('normalizes a single object body to a one-element array', () => { @@ -33,12 +41,12 @@ describe('parseQuestionTagBody', () => { }) it('preserves array order for multi-step bodies', () => { - const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, CONFIRM, TEXT])) - expect(parsed).toEqual([SINGLE_SELECT, CONFIRM, TEXT]) + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) }) - it('accepts text questions without options', () => { - expect(parseQuestionTagBody(JSON.stringify(TEXT))).toEqual([TEXT]) + it('accepts multi_select questions', () => { + expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) }) it('rejects single_select without options', () => { @@ -47,16 +55,37 @@ describe('parseQuestionTagBody', () => { ) }) - it('rejects confirm with empty options', () => { + it('rejects empty options', () => { expect( - parseQuestionTagBody(JSON.stringify({ type: 'confirm', prompt: 'Sure?', options: [] })) + parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] })) ).toBe(null) }) - it('rejects an unknown question type', () => { - expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, type: 'multi_select' }))).toBe( + it('rejects the removed text and confirm types', () => { + expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( null ) + expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) + }) + + it('strips agent-supplied catch-all options (the card provides its own)', () => { + const withOther = { + ...SINGLE_SELECT, + options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }], + } + expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT]) + }) + + it('rejects a question whose every option is a catch-all', () => { + const onlyOther = { + type: 'single_select', + prompt: 'Pick one', + options: [ + { id: 'a', label: 'Other' }, + { id: 'b', label: 'None of the above' }, + ], + } + expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null) }) it('rejects an empty prompt', () => { @@ -70,7 +99,9 @@ describe('parseQuestionTagBody', () => { }) it('rejects an array containing one invalid question', () => { - expect(parseQuestionTagBody(JSON.stringify([TEXT, { type: 'text' }]))).toBe(null) + expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe( + null + ) }) it('rejects empty arrays and non-JSON bodies', () => { @@ -92,9 +123,9 @@ describe('parseSpecialTags with ', () => { }) it('extracts a multi-step array body as one segment', () => { - const content = `${JSON.stringify([SINGLE_SELECT, CONFIRM, TEXT])}` + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` const { segments } = parseSpecialTags(content, false) - expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, CONFIRM, TEXT] }]) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) }) it('flags an unclosed question tag as pending while streaming', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index ace7a804504..4b03dc04460 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -95,7 +95,7 @@ export interface FileTagData { content: string } -export const QUESTION_TYPES = ['single_select', 'confirm', 'text'] as const +export const QUESTION_TYPES = ['single_select', 'multi_select'] as const export type QuestionType = (typeof QUESTION_TYPES)[number] @@ -105,14 +105,15 @@ export interface QuestionOption { } /** - * One question in a `` tag. `options` is required for - * `single_select` and `confirm`; `text` questions render only a free-text - * input. `single_select` always additionally offers a free-text "Other" row. + * One question in a `` tag. Both types require at least one option; + * the card always appends its own free-text "Something else" row, so + * agent-supplied catch-all options ("Other", "Something else", ...) are + * stripped during parsing. */ export interface QuestionItem { type: QuestionType prompt: string - options?: QuestionOption[] + options: QuestionOption[] } /** Normalized `` payload: single-object bodies become a one-element array. */ @@ -253,6 +254,19 @@ function isQuestionOption(value: unknown): value is QuestionOption { return typeof value.id === 'string' && typeof value.label === 'string' } +/** + * Catch-all labels the agent must not supply as options — the card renders + * its own free-text "Something else" row. Matching options are stripped; a + * question left with no real options is invalid. + */ +const SELF_PROVIDED_OPTION_LABELS = new Set([ + 'other', + 'others', + 'something else', + 'none of the above', + 'none of these', +]) + function isQuestionItem(value: unknown): value is QuestionItem { if (!isRecord(value)) return false if ( @@ -262,8 +276,6 @@ function isQuestionItem(value: unknown): value is QuestionItem { return false } if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false - // text questions carry no options; select/confirm require at least one. - if (value.type === 'text') return true return ( Array.isArray(value.options) && value.options.length > 0 && @@ -271,18 +283,44 @@ function isQuestionItem(value: unknown): value is QuestionItem { ) } +/** Strips agent-supplied catch-all options; null when none remain. */ +function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null { + const options = item.options.filter( + (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase()) + ) + if (options.length === 0) return null + return options.length === item.options.length ? item : { ...item, options } +} + /** * Parses a `` tag body. Accepts a single question object or a * non-empty array of them; single objects are normalized to a one-element * array so the renderer only handles the array shape. */ +/** + * Extracts the last complete `` tag payload from raw message + * content. Used by the chat list to pair an assistant question card with the + * user message that answered it. + */ +export function parseLastQuestionTag(content: string): QuestionTagData | null { + const matches = content.match(/([\s\S]*?)<\/question>/g) + if (!matches || matches.length === 0) return null + const last = matches[matches.length - 1] + return parseQuestionTagBody(last.slice(''.length, -''.length)) +} + export function parseQuestionTagBody(body: string): QuestionTagData | null { try { const parsed = JSON.parse(body) as unknown - if (Array.isArray(parsed)) { - return parsed.length > 0 && parsed.every(isQuestionItem) ? parsed : null + const items = Array.isArray(parsed) ? parsed : [parsed] + if (items.length === 0 || !items.every(isQuestionItem)) return null + const sanitized: QuestionItem[] = [] + for (const item of items) { + const clean = sanitizeQuestionItem(item) + if (!clean) return null + sanitized.push(clean) } - return isQuestionItem(parsed) ? [parsed] : null + return sanitized } catch { return null } @@ -472,6 +510,8 @@ const THINKING_BLOCKS = [ interface SpecialTagsProps { segment: Exclude + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void } @@ -482,6 +522,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, + questionAnswers, onOptionSelect, onWorkspaceResourceSelect, }: SpecialTagsProps) { @@ -499,7 +540,9 @@ export function SpecialTags({ case 'workspace_resource': return case 'question': - return + return ( + + ) default: return null } 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 e4286bf5e22..a72b6738f29 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 @@ -651,6 +651,8 @@ interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string isStreaming: boolean + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] onOptionSelect?: (id: string) => void onPhaseChange?: (phase: MessagePhase) => void } @@ -659,6 +661,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + questionAnswers, onOptionSelect, onPhaseChange, }: MessageContentProps) { @@ -724,6 +727,7 @@ function MessageContentInner({ segmentIndex: i, segmentCount: segments.length, })} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 841538b2e17..c85ef9aa7db 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -20,7 +20,11 @@ import { MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' -import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' +import { + PendingTagIndicator, + parseLastQuestionTag, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -163,6 +167,8 @@ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean precedingUserContent?: string + /** Transcript-derived answers for this message's question card (renders the recap). */ + questionAnswers?: string[] rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -172,6 +178,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, precedingUserContent, + questionAnswers, rowClassName, onOptionSelect, onAnimatingChange, @@ -197,7 +204,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - const showActions = phase === 'settled' && (message.content || hasAnyBlocks) + // A message that ends with a question card is an input surface, not a + // reactable assistant turn: no copy/thumbs row beneath the card, whether + // the card is awaiting answers or collapsed to its recap. + const endsWithQuestion = trimmedContent.endsWith('') + const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks) return (
@@ -205,6 +216,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onPhaseChange={setPhase} /> @@ -303,6 +315,29 @@ export function MothershipChat({ return out }, [messages]) + /** + * Pairs each assistant question card with the user message that answered it + * (strict `Prompt — Answer` match). The paired user message is hidden — the + * answered card IS the user turn — and the assistant row renders the card + * as a recap with these answers, both live and after reload. + */ + const questionPairing = useMemo(() => { + const answersByIndex: Array = [] + const hiddenUserByIndex: Array = [] + for (const [index, message] of messages.entries()) { + if (message.role !== 'assistant' || !message.content?.includes('')) continue + const next = messages[index + 1] + if (!next || next.role !== 'user' || !next.content) continue + const questions = parseLastQuestionTag(message.content) + if (!questions) continue + const answers = parseQuestionAnswerMessage(questions, next.content) + if (!answers) continue + answersByIndex[index] = answers + hiddenUserByIndex[index + 1] = true + } + return { answersByIndex, hiddenUserByIndex } + }, [messages]) + /** * Always keep the last row in the rendered window. It is the live/streaming * row; unmounting it (by scrolling far enough up that it leaves the overscan @@ -424,19 +459,22 @@ export function MothershipChat({ style={{ transform: `translateY(${virtualItem.start}px)` }} > {msg.role === 'user' ? ( - + questionPairing.hiddenUserByIndex[index] ? null : ( + + ) ) : ( Date: Sat, 4 Jul 2026 00:42:17 -0700 Subject: [PATCH 11/13] improvement(chat): question cards are single_select only Removes multi_select (and its toggle/check UI). The card is one shape: pick one option or type into the always-present 'Something else' row. Catch-all stripping and the transcript pairing/recap behavior are unchanged. Co-Authored-By: Claude Fable 5 --- .../components/question/question.test.ts | 2 +- .../components/question/question.tsx | 167 ++++++------------ .../special-tags/special-tags.test.ts | 26 ++- .../components/special-tags/special-tags.tsx | 6 +- 4 files changed, 65 insertions(+), 136 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index c78caf38e0b..67d305b3282 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -23,7 +23,7 @@ const QUESTIONS: QuestionItem[] = [ ], }, { - type: 'multi_select', + type: 'single_select', prompt: 'What time zone should the daily report run in?', options: [ { id: 'est', label: 'EST' }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 25c340ecc5f..99de487d1d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { ArrowRight, Button, Check, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' +import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' /** @@ -36,6 +36,16 @@ export function parseQuestionAnswerMessage( return answers } +/** + * The free-text input's initial value when (re)visiting a question: restore a + * previously typed answer, but not one that matches an option row (that row is + * highlighted instead). + */ +function freeTextPrefillFor(question: QuestionItem, answer: string | null): string { + if (!answer) return '' + return question.options.some((o) => o.label === answer) ? '' : answer +} + const OPTION_ROW_CLASSES = 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' @@ -69,11 +79,10 @@ interface QuestionDisplayProps { * Inline renderer for the `` special tag: a chat-inline div with the * user input's chrome, the current question's prompt at the top left, dismiss * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and - * suggested-action option rows beneath. Both question types append a - * free-text "Something else" row. `single_select` answers and advances on - * click; `multi_select` rows toggle and the free-text row's arrow submits the - * step. Answering the last question sends one combined user message and - * collapses the div to a question/answer recap. + * suggested-action option rows beneath, always followed by a free-text + * "Something else" row. Clicking an option (or submitting typed text) answers + * and advances; answering the last question sends one combined user message + * and collapses the div to a question/answer recap. */ export function QuestionDisplay({ data, @@ -83,8 +92,7 @@ export function QuestionDisplay({ const disabled = !onSelect const [phase, setPhase] = useState('active') const [step, setStep] = useState(0) - const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) - const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) + const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null)) const [freeText, setFreeText] = useState('') const containerClasses = @@ -92,13 +100,8 @@ export function QuestionDisplay({ // Transcript answers win over local state: they survive reloads (local // phase does not) and keep live + rehydrated renders identical. - const localAnswers = - phase === 'answered' - ? data.map((question, i) => - answerFor(question, selectedByStep[i] ?? [], customByStep[i] ?? '') - ) - : null - const recapAnswers = transcriptAnswers ?? localAnswers + const recapAnswers = + transcriptAnswers ?? (phase === 'answered' ? answers.map((a) => a ?? '') : null) if (data.length > 0 && recapAnswers) { return (
@@ -117,76 +120,30 @@ export function QuestionDisplay({ const question = data[step] const isLast = step === data.length - 1 const options = question.options - const selected = selectedByStep[step] ?? [] - const isMulti = question.type === 'multi_select' - - const commitCustom = (): string[] => { - const next = [...customByStep] - next[step] = freeText.trim() - setCustomByStep(next) - return next - } const goToStep = (next: number) => { - commitCustom() setStep(next) - setFreeText(customByStep[next] ?? '') + setFreeText(freeTextPrefillFor(data[next], answers[next])) } - const finishStep = (selections: string[][], customs: string[]) => { + const handleAnswer = (answer: string) => { + const next = [...answers] + next[step] = answer + setAnswers(next) if (!isLast) { - setStep(step + 1) - setFreeText(customs[step + 1] ?? '') + goToStep(step + 1) return } setPhase('answered') onSelect?.( formatQuestionAnswerMessage( data, - data.map((q, i) => answerFor(q, selections[i] ?? [], customs[i] ?? '')) + next.map((a) => a ?? '') ) ) } - const handleSingleSelect = (label: string) => { - const selections = [...selectedByStep] - selections[step] = [label] - setSelectedByStep(selections) - const customs = [...customByStep] - customs[step] = '' - setCustomByStep(customs) - setFreeText('') - finishStep(selections, customs) - } - - const handleMultiToggle = (label: string) => { - const selections = [...selectedByStep] - const current = selections[step] ?? [] - selections[step] = current.includes(label) - ? current.filter((l) => l !== label) - : [...current, label] - setSelectedByStep(selections) - } - - const submitFreeTextRow = () => { - const customs = commitCustom() - if (isMulti) { - finishStep(selectedByStep, customs) - return - } - const selections = [...selectedByStep] - selections[step] = [] - setSelectedByStep(selections) - finishStep(selections, customs) - } - - const stepAnswered = (i: number): boolean => - (selectedByStep[i]?.length ?? 0) > 0 || - (i === step ? freeText.trim().length > 0 : (customByStep[i] ?? '').trim().length > 0) - - // single_select: the arrow submits the typed "Something else" answer. - // multi_select: the arrow submits the step (selections and/or typed text). - const canSubmitRow = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) + const canSubmitFreeText = !disabled && freeText.trim().length > 0 return (
@@ -219,7 +176,7 @@ export function QuestionDisplay({ onClick={() => goToStep(step + 1)} // Inert renders (older messages) browse freely; interactive ones // gate forward movement on the current question being answered. - disabled={isLast || (!disabled && !stepAnswered(step))} + disabled={isLast || (!disabled && answers[step] === null)} className={cn( ICON_BUTTON_CLASSES, 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50' @@ -247,35 +204,24 @@ export function QuestionDisplay({
- {options.map((option, i) => { - const isSelected = selected.includes(option.label) - return ( - - ) - })} + {options.map((option, i) => ( + + ))}
0 && 'border-t')}> setFreeText(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmitRow) { + if (e.key === 'Enter' && canSubmitFreeText) { e.preventDefault() - submitFreeTextRow() + handleAnswer(freeText.trim()) } }} placeholder='Something else' @@ -296,14 +242,14 @@ export function QuestionDisplay({ @@ -312,16 +258,3 @@ export function QuestionDisplay({
) } - -/** - * A step's combined answer: selected option labels in option order, with the - * typed "Something else" entry appended last. single_select carries at most - * one selection, so this collapses to the chosen label or the typed text. - */ -function answerFor(question: QuestionItem, selected: string[], custom: string): string { - const ordered = question.options - .map((option) => option.label) - .filter((label) => selected.includes(label)) - const parts = custom.trim() ? [...ordered, custom.trim()] : ordered - return parts.join(', ') -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 638e3c3a747..5819cd4f28f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -25,13 +25,12 @@ const YES_NO = { ], } -const MULTI_SELECT = { - type: 'multi_select', - prompt: 'Which channels should the report go to?', +const TIMEZONE = { + type: 'single_select', + prompt: 'What time zone should the daily report run in?', options: [ - { id: 'slack', label: 'Slack' }, - { id: 'email', label: 'Email' }, - { id: 'sheet', label: 'Google Sheet' }, + { id: 'est', label: 'EST' }, + { id: 'pst', label: 'PST' }, ], } @@ -41,12 +40,8 @@ describe('parseQuestionTagBody', () => { }) it('preserves array order for multi-step bodies', () => { - const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) - expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) - }) - - it('accepts multi_select questions', () => { - expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, TIMEZONE]) }) it('rejects single_select without options', () => { @@ -61,11 +56,12 @@ describe('parseQuestionTagBody', () => { ).toBe(null) }) - it('rejects the removed text and confirm types', () => { + it('rejects non-single_select types', () => { expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( null ) expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) + expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'multi_select' }))).toBe(null) }) it('strips agent-supplied catch-all options (the card provides its own)', () => { @@ -123,9 +119,9 @@ describe('parseSpecialTags with ', () => { }) it('extracts a multi-step array body as one segment', () => { - const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE])}` const { segments } = parseSpecialTags(content, false) - expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, TIMEZONE] }]) }) it('flags an unclosed question tag as pending while streaming', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 4b03dc04460..e8337f69fd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -95,7 +95,7 @@ export interface FileTagData { content: string } -export const QUESTION_TYPES = ['single_select', 'multi_select'] as const +export const QUESTION_TYPES = ['single_select'] as const export type QuestionType = (typeof QUESTION_TYPES)[number] @@ -105,8 +105,8 @@ export interface QuestionOption { } /** - * One question in a `` tag. Both types require at least one option; - * the card always appends its own free-text "Something else" row, so + * One question in a `` tag: a single_select with at least one real + * option. The card always appends its own free-text "Something else" row, so * agent-supplied catch-all options ("Other", "Something else", ...) are * stripped during parsing. */ From 30ef540a3df4166d5ce85749a31e37f5ba7f8cf7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 4 Jul 2026 13:29:35 -0700 Subject: [PATCH 12/13] improvement(chat): bring back multi_select question cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds multi_select with a reworked interaction: option rows carry real checkboxes (emcn Checkbox chrome) instead of numbers and arrows, an option-styled Submit row confirms the step, and the "Something else" row reads as a plain option until clicked — then it becomes the focused text box, auto-checks, and can be unchecked without losing the typed text (blur with nothing typed reverts it). single_select behavior, catch-all stripping, and the transcript pairing/recap format are unchanged; multi_select answers are the checked labels comma-joined. --- .../components/question/question.test.ts | 4 +- .../components/question/question.tsx | 344 ++++++++++++++---- .../special-tags/special-tags.test.ts | 26 +- .../components/special-tags/special-tags.tsx | 10 +- packages/emcn/src/components/index.ts | 2 +- 5 files changed, 304 insertions(+), 82 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index 67d305b3282..f3d7b9528ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -23,7 +23,7 @@ const QUESTIONS: QuestionItem[] = [ ], }, { - type: 'single_select', + type: 'multi_select', prompt: 'What time zone should the daily report run in?', options: [ { id: 'est', label: 'EST' }, @@ -50,7 +50,7 @@ describe('formatQuestionAnswerMessage', () => { describe('parseQuestionAnswerMessage', () => { it('round-trips what formatQuestionAnswerMessage produces', () => { - const answers = ['Keep the newest entry', 'Cancel', 'EST'] + const answers = ['Keep the newest entry', 'Cancel', 'EST, PST'] const message = formatQuestionAnswerMessage(QUESTIONS, answers) expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 99de487d1d2..f3e19b0ef3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -1,7 +1,17 @@ 'use client' import { useState } from 'react' -import { ArrowRight, Button, ChevronLeft, ChevronRight, cn, X } from '@sim/emcn' +import { + ArrowRight, + Button, + Check, + checkboxIconVariants, + checkboxVariants, + ChevronLeft, + ChevronRight, + cn, + X, +} from '@sim/emcn' import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' /** @@ -36,16 +46,6 @@ export function parseQuestionAnswerMessage( return answers } -/** - * The free-text input's initial value when (re)visiting a question: restore a - * previously typed answer, but not one that matches an option row (that row is - * highlighted instead). - */ -function freeTextPrefillFor(question: QuestionItem, answer: string | null): string { - if (!answer) return '' - return question.options.some((o) => o.label === answer) ? '' : answer -} - const OPTION_ROW_CLASSES = 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' @@ -61,6 +61,28 @@ function RowNumber({ value }: { value: number }) { ) } +/** + * Leading checkbox slot for multi_select rows. Purely presentational — it + * reuses the emcn Checkbox chrome via its exported variants, but the row + * button (or the free-text input) owns the interaction, so nesting a real + * Radix checkbox button inside the row button is avoided. + */ +function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) { + return ( +
+ + {checked && ( + + )} + +
+ ) +} + type QuestionPhase = 'active' | 'answered' | 'dismissed' interface QuestionDisplayProps { @@ -79,10 +101,12 @@ interface QuestionDisplayProps { * Inline renderer for the `` special tag: a chat-inline div with the * user input's chrome, the current question's prompt at the top left, dismiss * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and - * suggested-action option rows beneath, always followed by a free-text - * "Something else" row. Clicking an option (or submitting typed text) answers - * and advances; answering the last question sends one combined user message - * and collapses the div to a question/answer recap. + * suggested-action option rows beneath, always followed by a "Something else" + * row that reads as a plain option until clicked and then becomes the focused + * text box. `single_select` answers and advances on click (or on submitting + * typed text); `multi_select` rows toggle checkboxes and an option-styled + * Submit row confirms the step. Answering the last question sends one + * combined user message and collapses the div to a question/answer recap. */ export function QuestionDisplay({ data, @@ -92,16 +116,35 @@ export function QuestionDisplay({ const disabled = !onSelect const [phase, setPhase] = useState('active') const [step, setStep] = useState(0) - const [answers, setAnswers] = useState<(string | null)[]>(() => data.map(() => null)) + const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) + const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) const [freeText, setFreeText] = useState('') + // The "Something else" row reads as a plain option until clicked, then + // becomes the focused text box (and reverts when left empty). + const [freeTextEditing, setFreeTextEditing] = useState(false) + // multi_select only: whether the typed "Something else" text is included in + // the answer. Unchecking keeps the text; it just stops counting. + const [customCheckedByStep, setCustomCheckedByStep] = useState(() => + data.map(() => false) + ) + + // The typed text that actually joins a step's answer: multi_select customs + // only count while checked; single_select customs always count. + const customFor = (i: number, customs: string[]): string => + data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') const containerClasses = 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' // Transcript answers win over local state: they survive reloads (local // phase does not) and keep live + rehydrated renders identical. - const recapAnswers = - transcriptAnswers ?? (phase === 'answered' ? answers.map((a) => a ?? '') : null) + const localAnswers = + phase === 'answered' + ? data.map((question, i) => + answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep)) + ) + : null + const recapAnswers = transcriptAnswers ?? localAnswers if (data.length > 0 && recapAnswers) { return (
@@ -120,30 +163,90 @@ export function QuestionDisplay({ const question = data[step] const isLast = step === data.length - 1 const options = question.options + const selected = selectedByStep[step] ?? [] + const isMulti = question.type === 'multi_select' + + const commitCustom = (): string[] => { + const next = [...customByStep] + next[step] = freeText.trim() + setCustomByStep(next) + return next + } const goToStep = (next: number) => { + commitCustom() setStep(next) - setFreeText(freeTextPrefillFor(data[next], answers[next])) + const prefill = customByStep[next] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) } - const handleAnswer = (answer: string) => { - const next = [...answers] - next[step] = answer - setAnswers(next) + const finishStep = (selections: string[][], customs: string[]) => { if (!isLast) { - goToStep(step + 1) + setStep(step + 1) + const prefill = customs[step + 1] ?? '' + setFreeText(prefill) + setFreeTextEditing(prefill.trim().length > 0) return } setPhase('answered') onSelect?.( formatQuestionAnswerMessage( data, - next.map((a) => a ?? '') + data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs))) ) ) } - const canSubmitFreeText = !disabled && freeText.trim().length > 0 + const handleSingleSelect = (label: string) => { + const selections = [...selectedByStep] + selections[step] = [label] + setSelectedByStep(selections) + const customs = [...customByStep] + customs[step] = '' + setCustomByStep(customs) + setFreeText('') + finishStep(selections, customs) + } + + const handleMultiToggle = (label: string) => { + const selections = [...selectedByStep] + const current = selections[step] ?? [] + selections[step] = current.includes(label) + ? current.filter((l) => l !== label) + : [...current, label] + setSelectedByStep(selections) + } + + /** multi_select confirm: commits selections and/or typed text, then advances. */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) + } + + /** Sets whether the typed "Something else" text counts — never touches the text. */ + const setCustomChecked = (checked: boolean) => { + const next = [...customCheckedByStep] + next[step] = checked + setCustomCheckedByStep(next) + } + + /** single_select free-text arrow: the typed text IS the answer. */ + const submitSingleFreeText = () => { + const customs = commitCustom() + const selections = [...selectedByStep] + selections[step] = [] + setSelectedByStep(selections) + finishStep(selections, customs) + } + + const stepAnswered = (i: number): boolean => { + if ((selectedByStep[i]?.length ?? 0) > 0) return true + const text = i === step ? freeText : (customByStep[i] ?? '') + if (text.trim().length === 0) return false + return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true + } + + const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) return (
@@ -176,7 +279,7 @@ export function QuestionDisplay({ onClick={() => goToStep(step + 1)} // Inert renders (older messages) browse freely; interactive ones // gate forward movement on the current question being answered. - disabled={isLast || (!disabled && answers[step] === null)} + disabled={isLast || (!disabled && !stepAnswered(step))} className={cn( ICON_BUTTON_CLASSES, 'before:absolute before:inset-[-8px] before:content-[""] disabled:opacity-50' @@ -204,57 +307,172 @@ export function QuestionDisplay({
- {options.map((option, i) => ( + {options.map((option, i) => { + const isSelected = selected.includes(option.label) + return ( + + ) + })} + {freeTextEditing ? ( +
0 && 'border-t')}> + {isMulti ? ( + // Checked from the moment the row is clicked into; blur with + // nothing typed reverts to the plain option row. A real button + // (the editing row is a div, so no nesting hazard) so the box + // can be toggled even after typing — unchecking keeps the text, + // it just stops counting toward the answer. +
+ +
+ ) : ( + + )} + setFreeText(e.target.value)} + onBlur={() => { + if (freeText.trim().length === 0) { + setFreeTextEditing(false) + if (isMulti) setCustomChecked(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Enter' && canSubmitStep) { + e.preventDefault() + if (isMulti) { + submitMultiStep() + } else { + submitSingleFreeText() + } + } + }} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none disabled:cursor-not-allowed' + /> + {!isMulti && ( + + )} +
+ ) : ( - ))} -
0 && 'border-t')}> - - setFreeText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && canSubmitFreeText) { - e.preventDefault() - handleAnswer(freeText.trim()) - } - }} - placeholder='Something else' - aria-label={question.prompt} - className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' - /> + )} + {isMulti && ( -
+ )}
) } + +/** + * A step's combined answer: selected option labels in option order, with the + * typed "Something else" entry appended last. single_select carries at most + * one selection, so this collapses to the chosen label or the typed text. + */ +function answerFor(question: QuestionItem, selected: string[], custom: string): string { + const ordered = question.options + .map((option) => option.label) + .filter((label) => selected.includes(label)) + const parts = custom.trim() ? [...ordered, custom.trim()] : ordered + return parts.join(', ') +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 5819cd4f28f..638e3c3a747 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -25,12 +25,13 @@ const YES_NO = { ], } -const TIMEZONE = { - type: 'single_select', - prompt: 'What time zone should the daily report run in?', +const MULTI_SELECT = { + type: 'multi_select', + prompt: 'Which channels should the report go to?', options: [ - { id: 'est', label: 'EST' }, - { id: 'pst', label: 'PST' }, + { id: 'slack', label: 'Slack' }, + { id: 'email', label: 'Email' }, + { id: 'sheet', label: 'Google Sheet' }, ], } @@ -40,8 +41,12 @@ describe('parseQuestionTagBody', () => { }) it('preserves array order for multi-step bodies', () => { - const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE])) - expect(parsed).toEqual([SINGLE_SELECT, YES_NO, TIMEZONE]) + const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) + expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) + }) + + it('accepts multi_select questions', () => { + expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) }) it('rejects single_select without options', () => { @@ -56,12 +61,11 @@ describe('parseQuestionTagBody', () => { ).toBe(null) }) - it('rejects non-single_select types', () => { + it('rejects the removed text and confirm types', () => { expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( null ) expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) - expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'multi_select' }))).toBe(null) }) it('strips agent-supplied catch-all options (the card provides its own)', () => { @@ -119,9 +123,9 @@ describe('parseSpecialTags with ', () => { }) it('extracts a multi-step array body as one segment', () => { - const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, TIMEZONE])}` + const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` const { segments } = parseSpecialTags(content, false) - expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, TIMEZONE] }]) + expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) }) it('flags an unclosed question tag as pending while streaming', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index e8337f69fd6..73109b106da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -95,7 +95,7 @@ export interface FileTagData { content: string } -export const QUESTION_TYPES = ['single_select'] as const +export const QUESTION_TYPES = ['single_select', 'multi_select'] as const export type QuestionType = (typeof QUESTION_TYPES)[number] @@ -105,10 +105,10 @@ export interface QuestionOption { } /** - * One question in a `` tag: a single_select with at least one real - * option. The card always appends its own free-text "Something else" row, so - * agent-supplied catch-all options ("Other", "Something else", ...) are - * stripped during parsing. + * One question in a `` tag: a single_select or multi_select with at + * least one real option. The card always appends its own free-text "Something + * else" row, so agent-supplied catch-all options ("Other", "Something else", + * ...) are stripped during parsing. */ export interface QuestionItem { type: QuestionType diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index fedc7d65fec..b9b3d37165c 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -7,7 +7,7 @@ export { CalendarDayCell, type CalendarDayCellProps, } from './calendar/calendar-day-cell' -export { Checkbox } from './checkbox/checkbox' +export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox' export { Chip, ChipLink, From 0a80bd20772de8f19b6ab5599c9a0af0026043f4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 4 Jul 2026 16:27:52 -0700 Subject: [PATCH 13/13] chore(copilot): regenerate mothership contract mirror (chat blob span attrs) --- apps/sim/lib/copilot/generated/trace-attributes-v1.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 4bb494c2d57..a013bc95844 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -89,6 +89,10 @@ export const TraceAttr = { ChatArtifactKeys: 'chat.artifact_keys', ChatArtifactsBytes: 'chat.artifacts_bytes', ChatAuthType: 'chat.auth_type', + ChatBlobBytesOffloaded: 'chat.blob_bytes_offloaded', + ChatBlobBytesResolved: 'chat.blob_bytes_resolved', + ChatBlobsOffloaded: 'chat.blobs_offloaded', + ChatBlobsResolved: 'chat.blobs_resolved', ChatContextCount: 'chat.context_count', ChatContextUsage: 'chat.context_usage', ChatContinuationMessagesBefore: 'chat.continuation.messages_before', @@ -719,6 +723,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'chat.artifact_keys', 'chat.artifacts_bytes', 'chat.auth_type', + 'chat.blob_bytes_offloaded', + 'chat.blob_bytes_resolved', + 'chat.blobs_offloaded', + 'chat.blobs_resolved', 'chat.context_count', 'chat.context_usage', 'chat.continuation.messages_before',