From b93cebf19c81dadfb345f57c5873a95efd71227a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Jul 2026 12:11:50 -0700 Subject: [PATCH 01/10] fix(mothership): credential connect names, highlighted line, knowledge of connection --- .../rich-markdown-editor/keymap.test.ts | 16 ++++++++ .../rich-markdown-editor/keymap.ts | 38 ++++++++++++++++--- .../special-tags/special-tags.test.tsx | 21 +++------- .../components/special-tags/special-tags.tsx | 9 ++++- .../copilot/chat/workspace-context.test.ts | 10 +++++ .../sim/lib/copilot/chat/workspace-context.ts | 16 +++++++- 6 files changed, 84 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index b9392de02c8..586f2bc2ff2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -166,6 +166,22 @@ describe('divider Backspace', () => { expect(selection.to).toBe(doc.content.size) editor.destroy() }) + + it('only paints a divider inside a range selection while the editor has focus', () => { + const editor = editorWith('

a


b

') + const divider = editor.view.dom.querySelector('hr') + editor.commands.selectAll() + + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) + + editor.view.dom.dispatchEvent(new FocusEvent('focus')) + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(true) + + editor.view.dom.dispatchEvent(new FocusEvent('blur')) + expect(editor.state.selection.empty).toBe(false) + expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) + editor.destroy() + }) }) describe('empty wrapped-block Backspace', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index 0d5bc22801c..bdbac57bfc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -20,6 +20,8 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) /** Item node types a list is built from, used to detect an empty item's position within its list. */ const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) +const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey('richLeafSelectionFocus') + /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ function isInsideWrapper($from: ResolvedPos): boolean { for (let depth = $from.depth - 1; depth >= 1; depth--) { @@ -157,10 +159,11 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately * in `./block-mover.ts`.) * - * Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all), - * which the browser's native text highlight skips because leaves carry no text, and (b) flags the - * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its - * otherwise-stray caret. + * Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g. + * select-all), which the browser's native text highlight skips because leaves carry no text; hiding + * that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the + * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide + * its otherwise-stray caret. */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -231,11 +234,34 @@ export const RichMarkdownKeymap = Extension.create({ addProseMirrorPlugins() { return [ new Plugin({ - key: new PluginKey('richLeafSelectionHighlight'), + key: RICH_LEAF_SELECTION_FOCUS_KEY, + state: { + init: () => false, + apply(transaction, focused) { + const nextFocused = transaction.getMeta(RICH_LEAF_SELECTION_FOCUS_KEY) + return typeof nextFocused === 'boolean' ? nextFocused : focused + }, + }, props: { + handleDOMEvents: { + focus(view) { + view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, true)) + return false + }, + blur(view) { + view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, false)) + return false + }, + }, decorations(state) { const { selection } = state - if (selection.empty || selection instanceof NodeSelection) return null + if ( + RICH_LEAF_SELECTION_FOCUS_KEY.getState(state) !== true || + selection.empty || + selection instanceof NodeSelection + ) { + return null + } const decorations: Decoration[] = [] state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { if (SELECTABLE_LEAVES.has(node.type.name)) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index a23bc0427d5..8791ddd3d05 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -72,17 +72,17 @@ describe('CredentialDisplay link tag', () => { }) it('renders a working link for a real http(s) connect URL', () => { - const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo' + const url = 'https://sim.test/api/auth/oauth2/authorize?providerId=google-drive' const { container, root } = renderCredentialLink({ type: 'link', - provider: 'github', + provider: 'google-drive', value: url, }) const link = container.querySelector('a') expect(link).not.toBeNull() expect(link?.getAttribute('href')).toBe(url) - expect(container.textContent).toContain('Connect github') + expect(container.textContent).toContain('Connect Google Drive') act(() => root.unmount()) }) @@ -98,17 +98,6 @@ describe('CredentialDisplay link tag', () => { act(() => root.unmount()) }) - it('does not query a credential for a plain connect URL', () => { - const { root } = renderCredentialLink({ - type: 'link', - provider: 'github', - value: 'https://github.com/login/oauth/authorize?client_id=abc', - }) - - expect(mockUseWorkspaceCredential).toHaveBeenCalledWith(undefined) - act(() => root.unmount()) - }) - it('labels a reconnect URL with the credential display name', () => { mockUseWorkspaceCredential.mockReturnValue({ data: { id: 'cred-1', displayName: "Justin's Gmail" }, @@ -125,7 +114,7 @@ describe('CredentialDisplay link tag', () => { act(() => root.unmount()) }) - it('falls back to the provider label while the reconnect credential is unresolved', () => { + it('falls back to the integration name while the reconnect credential is unresolved', () => { const { container, root } = renderCredentialLink({ type: 'link', provider: 'google-email', @@ -133,7 +122,7 @@ describe('CredentialDisplay link tag', () => { 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', }) - expect(container.textContent).toContain('Reconnect google-email') + expect(container.textContent).toContain('Reconnect Gmail') act(() => root.unmount()) }) }) 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 31df87e34ae..f85b46d58f4 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 @@ -20,6 +20,7 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' 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 { @@ -878,9 +879,13 @@ function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { // render it as a clickable link when it resolves to a real http(s) URL. if (!data.value || !isSafeHttpUrl(data.value)) return null const Icon = getCredentialIcon(data.provider) ?? LockIcon + const integrationName = + getServiceConfigByProviderId(data.provider)?.name ?? + OAUTH_PROVIDERS[data.provider.toLowerCase()]?.name ?? + data.provider const label = reconnectCredentialId - ? `Reconnect ${reconnectCredential?.displayName ?? data.provider}` - : `Connect ${data.provider}` + ? `Reconnect ${reconnectCredential?.displayName ?? integrationName}` + : `Connect ${integrationName}` return ( { const md = buildWorkspaceMd(baseData({ oauthIntegrations: [] })) expect(md).toContain('## Connected Integrations\n(none)') }) + + it('injects available environment credential names into markdown and the typed snapshot', () => { + const data = baseData({ envVariables: ['OPENAI_API_KEY', 'STRIPE_SECRET_KEY'] }) + + const md = buildWorkspaceMd(data) + expect(md).toContain('## Environment Variables (2)') + expect(md).toContain('- OPENAI_API_KEY') + expect(md).toContain('- STRIPE_SECRET_KEY') + expect(buildVfsSnapshot(data).envVars).toEqual(['OPENAI_API_KEY', 'STRIPE_SECRET_KEY']) + }) }) describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 0eed19fdee8..a46f3764d94 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -19,7 +19,10 @@ import type { } from '@/lib/copilot/generated/vfs-snapshot-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' +import { + getAccessibleEnvCredentials, + getAccessibleOAuthCredentials, +} from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -350,6 +353,7 @@ async function buildWorkspaceMdData( tables, files, credentials, + envCredentials, customTools, mcpServerRows, skillRows, @@ -406,6 +410,8 @@ async function buildWorkspaceMdData( getAccessibleOAuthCredentials(workspaceId, userId), + getAccessibleEnvCredentials(workspaceId, userId), + listCustomTools({ userId, workspaceId }), db @@ -511,7 +517,13 @@ async function buildWorkspaceMdData( displayName: c.displayName, role: c.role, })), - envVariables: [], + // Names only: make newly saved personal/workspace secrets visible to the + // next Mothership turn without ever putting their values on the wire. + // De-duplicate conflicts (the same key may exist in both scopes) and sort + // for byte-stable prompt snapshots. + envVariables: [...new Set(envCredentials.map((credential) => credential.envKey))].sort( + stableCompare + ), customTools: customTools.map((t) => ({ id: t.id, name: t.title })), customBlocks: customBlockSummaries, mcpServers: mcpServerRows, From c958cf3c4155830d47242a192db207a81202bd0c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Jul 2026 12:52:08 -0700 Subject: [PATCH 02/10] fix(mothership): webhook url is now visible to the mothership --- .../workflow/edit-workflow/validation.test.ts | 56 ++- .../workflow/edit-workflow/validation.ts | 33 +- .../lib/copilot/tools/tool-display.test.ts | 202 +++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 326 +++++++++++++++++- .../sanitization/json-sanitizer.test.ts | 144 +++++++- .../workflows/sanitization/json-sanitizer.ts | 51 +++ apps/sim/triggers/constants.ts | 10 + 7 files changed, 813 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index 2d18a244180..e9d8c30e5d4 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -127,6 +127,17 @@ const throwGateBlockConfig = { tools: { access: ['throw_gate_tool'], config: { tool: () => 'throw_gate_tool' } }, } +const genericWebhookBlockConfig = { + type: 'generic_webhook', + name: 'Webhook', + category: 'triggers', + outputs: {}, + subBlocks: [ + { id: 'webhookUrlDisplay', type: 'short-input', readOnly: true, useWebhookUrl: true }, + { id: 'requireAuth', type: 'switch' }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -192,7 +203,9 @@ vi.mock('@/blocks/registry', () => ({ ? throwGateBlockConfig : type === 'throw_selector_block' ? throwSelectorBlockConfig - : undefined, + : type === 'generic_webhook' + ? genericWebhookBlockConfig + : undefined, })) vi.mock('@/blocks/utils', () => ({ @@ -305,6 +318,47 @@ describe('validateInputsForBlock', () => { expect(result.validInputs.tagFilters).toBeNull() }) + // The webhook URL is shown to the agent as a synthesized read-only field + // (sanitizeForCopilot); writes to it or to display-only subblocks must bounce + // with a clear error instead of persisting dead state. + it('rejects the synthesized triggerWebhookUrl field as read-only', () => { + const result = validateInputsForBlock( + 'generic_webhook', + { triggerWebhookUrl: 'https://evil.test/api/webhooks/trigger/x', requireAuth: true }, + 'hook-1' + ) + + expect(result.validInputs.triggerWebhookUrl).toBeUndefined() + expect(result.validInputs.requireAuth).toBe(true) + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('read-only') + }) + + it('rejects triggerWebhookUrl even for unknown block types that skip validation', () => { + const result = validateInputsForBlock( + 'not_a_real_block', + { triggerWebhookUrl: 'https://evil.test/hook', other: 'kept' }, + 'blk-1' + ) + + expect(result.validInputs.triggerWebhookUrl).toBeUndefined() + expect(result.validInputs.other).toBe('kept') + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('read-only') + }) + + it('rejects read-only display subblocks like webhookUrlDisplay', () => { + const result = validateInputsForBlock( + 'generic_webhook', + { webhookUrlDisplay: 'https://evil.test/hook' }, + 'hook-1' + ) + + expect(result.validInputs.webhookUrlDisplay).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('read-only') + }) + it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index c8c02734c03..9dd6e65a806 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' @@ -17,7 +18,7 @@ import { getModelOptions } from '@/blocks/utils' import { EDGE, normalizeName } from '@/executor/constants' import { isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' import { getTool } from '@/tools/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' +import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' import type { EdgeHandleValidationResult, EditWorkflowOperation, @@ -55,12 +56,27 @@ export function validateInputsForBlock( blockId: string ): ValidationResult { const errors: ValidationError[] = [] + + // Synthesized by sanitizeForCopilot in the read view; derived, never stored. + // Rejected before the unknown-block-type early return below so it can never be + // written regardless of block type. + if (TRIGGER_WEBHOOK_URL_FIELD in inputs) { + errors.push({ + blockId, + blockType, + field: TRIGGER_WEBHOOK_URL_FIELD, + value: inputs[TRIGGER_WEBHOOK_URL_FIELD], + error: `"${TRIGGER_WEBHOOK_URL_FIELD}" is read-only. The webhook URL is auto-assigned by Sim and cannot be changed by the agent or the user.`, + }) + inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD]) + } + const blockConfig = getBlock(blockType) if (!blockConfig) { // Unknown block type - return inputs as-is (let it fail later if invalid) validationLogger.warn(`Unknown block type: ${blockType}, skipping validation`) - return { validInputs: inputs, errors: [] } + return { validInputs: inputs, errors } } const validatedInputs: Record = {} @@ -97,6 +113,19 @@ export function validateInputsForBlock( continue } + // Display-only fields (e.g. webhookUrlDisplay, samplePayload) are computed or + // static in the UI; a written value would be dead state the UI never shows. + if (subBlockConfig.readOnly === true) { + errors.push({ + blockId, + blockType, + field: key, + value, + error: `Field "${key}" on block type "${blockType}" is a read-only display field and cannot be set`, + }) + continue + } + // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 64a66c27ac4..5b016fad581 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -2,6 +2,17 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { + FfmpegOperationValues, + KnowledgeBaseOperationValues, + MaterializeFileOperationValues, + QueryUserTableOperationValues, + SearchKnowledgeBaseOperationValues, + TOOL_CATALOG, + type ToolCatalogEntry, + UserTableOperationValues, +} from '@/lib/copilot/generated/tool-catalog-v1' +import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' import { getToolCompletedTitle, getToolDisplayTitle, @@ -9,6 +20,28 @@ import { mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' +function representativeToolArgs(entry: ToolCatalogEntry): Record { + const args: Record = {} + if (!entry.parameters || typeof entry.parameters !== 'object') return args + const properties = (entry.parameters as { properties?: unknown }).properties + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args + + for (const [key, rawSchema] of Object.entries(properties)) { + if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue + const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown } + if (schema.default !== undefined) { + args[key] = schema.default + } else if (Array.isArray(schema.enum) && schema.enum.length > 0) { + args[key] = schema.enum[0] + } else if (schema.type === 'boolean') { + args[key] = true + } else if (schema.type === 'object') { + args[key] = {} + } + } + return args +} + describe('humanizeToolName', () => { it('title-cases snake_case names', () => { expect(humanizeToolName('manage_folder')).toBe('Manage Folder') @@ -35,6 +68,42 @@ describe('getToolDisplayTitle natural-language coverage', () => { 'Crunching numbers' ) }) + + it('has an intentional display title for every visible catalog tool', () => { + const hiddenToolNames = getHiddenToolNames() + const fallbackToolNames = Object.keys(TOOL_CATALOG).filter( + (name) => !hiddenToolNames.has(name) && getToolDisplayTitle(name) === humanizeToolName(name) + ) + + expect(fallbackToolNames).toEqual([]) + }) + + it('has a completed-verb rewrite for every visible non-agent catalog tool', () => { + const hiddenToolNames = getHiddenToolNames() + const missingCompletedVerbs = Object.entries(TOOL_CATALOG).flatMap(([name, entry]) => { + if (entry.internal || hiddenToolNames.has(name)) return [] + const title = getToolDisplayTitle(name, representativeToolArgs(entry)) + return getToolCompletedTitle(title) ? [] : [`${name}: ${title}`] + }) + + expect(missingCompletedVerbs).toEqual([]) + }) +}) + +describe('getToolDisplayTitle for deployments', () => { + it.each([ + ['deploy_api', undefined, 'Deploying API'], + ['deploy_api', { action: 'deploy' }, 'Deploying API'], + ['deploy_api', { action: 'undeploy' }, 'Undeploying API'], + ['deploy_chat', { action: 'deploy' }, 'Deploying chat'], + ['deploy_chat', { action: 'undeploy' }, 'Undeploying chat'], + ['deploy_custom_block', { action: 'deploy' }, 'Deploying custom block'], + ['deploy_custom_block', { action: 'undeploy' }, 'Undeploying custom block'], + ['deploy_mcp', undefined, 'Deploying MCP tool'], + ['redeploy', undefined, 'Redeploying API'], + ])('uses the action and deployment type for %s', (toolName, args, expected) => { + expect(getToolDisplayTitle(toolName, args)).toBe(expected) + }) }) describe('getToolCompletedTitle', () => { @@ -49,6 +118,10 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') expect(getToolCompletedTitle('Reading file')).toBe('Read file') + expect(getToolCompletedTitle('Undeploying API')).toBe('Undeployed API') + expect(getToolCompletedTitle('Duplicating workflow')).toBe('Duplicated workflow') + expect(getToolCompletedTitle('Viewing custom tools')).toBe('Viewed custom tools') + expect(getToolCompletedTitle('Saving report.pdf')).toBe('Saved report.pdf') }) it('returns undefined for non-gerund titles', () => { @@ -93,6 +166,11 @@ describe('getToolDisplayTitle for the vfs verbs', () => { }) ).toBe('Creating Quarterly Report.pdf') expect(getToolDisplayTitle('create_file', { fileName: 'notes.md' })).toBe('Creating notes.md') + expect( + getToolDisplayTitle('create_file', { + outputs: { files: [{ path: 'files/notes.md', mode: 'overwrite' }] }, + }) + ).toBe('Overwriting notes.md') expect(getToolDisplayTitle('create_file')).toBe('Creating file') }) @@ -198,6 +276,130 @@ describe('getToolDisplayTitle for managed resources', () => { }) }) +describe('getToolDisplayTitle for operation-driven tools', () => { + it('covers every FFmpeg operation with a specific activity', () => { + for (const operation of FfmpegOperationValues) { + expect(getToolDisplayTitle('ffmpeg', { operation })).not.toBe('Processing media') + } + expect(getToolDisplayTitle('ffmpeg', { operation: 'probe' })).toBe('Inspecting media') + expect(getToolDisplayTitle('ffmpeg', { operation: 'extract_audio' })).toBe('Extracting audio') + }) + + it('covers every knowledge-base operation with its actual verb and resource', () => { + for (const operation of KnowledgeBaseOperationValues) { + expect(getToolDisplayTitle('knowledge_base', { operation })).not.toBe( + 'Managing knowledge base' + ) + } + expect(getToolDisplayTitle('knowledge_base', { operation: 'query' })).toBe( + 'Searching knowledge base' + ) + expect(getToolDisplayTitle('knowledge_base', { operation: 'sync_connector' })).toBe( + 'Syncing knowledge base connector' + ) + }) + + it('covers every read-only table and knowledge-base operation', () => { + for (const operation of QueryUserTableOperationValues) { + expect(getToolDisplayTitle('query_user_table', { operation })).not.toBe('Query User Table') + } + for (const operation of SearchKnowledgeBaseOperationValues) { + expect(getToolDisplayTitle('search_knowledge_base', { operation })).not.toBe( + 'Search Knowledge Base' + ) + } + expect(getToolDisplayTitle('query_user_table', { operation: 'get_schema' })).toBe( + 'Reading table schema' + ) + expect(getToolDisplayTitle('search_knowledge_base', { operation: 'list_tags' })).toBe( + 'Listing knowledge base tags' + ) + }) + + it('covers every table operation with a specific activity', () => { + for (const operation of UserTableOperationValues) { + expect(getToolDisplayTitle('user_table', { operation })).not.toBe('Managing table') + } + expect( + getToolDisplayTitle('user_table', { + operation: 'rename_column', + args: { columnName: 'status', newName: 'stage' }, + }) + ).toBe('Renaming column status to stage') + expect(getToolDisplayTitle('user_table', { operation: 'cancel_table_runs' })).toBe( + 'Cancelling table runs' + ) + }) + + it('distinguishes saving uploads from importing workflows', () => { + for (const operation of MaterializeFileOperationValues) { + expect( + getToolDisplayTitle('materialize_file', { operation, fileNames: ['Lead Router.json'] }) + ).not.toBe('Preparing file') + } + expect( + getToolDisplayTitle('materialize_file', { + operation: 'save', + fileNames: ['Quarterly Report.pdf'], + }) + ).toBe('Saving Quarterly Report.pdf') + expect( + getToolDisplayTitle('materialize_file', { + operation: 'import', + fileNames: ['Lead Router.json'], + }) + ).toBe('Importing Lead Router.json') + }) + + it('uses boolean and resource-type arguments where they change the action', () => { + expect(getToolDisplayTitle('set_block_enabled', { enabled: true })).toBe('Enabling block') + expect(getToolDisplayTitle('set_block_enabled', { enabled: false })).toBe('Disabling block') + expect(getToolDisplayTitle('restore_resource', { type: 'knowledgebase' })).toBe( + 'Restoring knowledge base' + ) + expect(getToolDisplayTitle('open_resource', { resources: [{ type: 'scheduledtask' }] })).toBe( + 'Opening scheduled task' + ) + }) + + it('includes deployment versions when available', () => { + expect(getToolDisplayTitle('load_deployment', { version: 'live' })).toBe( + 'Loading live deployment' + ) + expect(getToolDisplayTitle('load_deployment', { version: '5' })).toBe( + 'Loading deployment version 5' + ) + expect(getToolDisplayTitle('promote_to_live', { version: 5 })).toBe( + 'Promoting version 5 to live' + ) + expect(getToolDisplayTitle('update_deployment_version', { version: 5 })).toBe( + 'Updating deployment version 5' + ) + }) + + it('uses the integration, variable scope, and nested variable operations', () => { + expect(getToolDisplayTitle('list_integration_tools', { integration: 'google_sheets' })).toBe( + 'Listing Google Sheets tools' + ) + expect(getToolDisplayTitle('set_environment_variables', { scope: 'personal' })).toBe( + 'Setting personal environment variables' + ) + expect( + getToolDisplayTitle('set_global_workflow_variables', { + operations: [{ operation: 'delete', name: 'OLD_URL' }], + }) + ).toBe('Deleting workflow variable OLD_URL') + expect( + getToolDisplayTitle('set_global_workflow_variables', { + operations: [ + { operation: 'add', name: 'API_URL' }, + { operation: 'edit', name: 'TIMEOUT' }, + ], + }) + ).toBe('Updating 2 workflow variables') + }) +}) + describe('getToolDisplayTitle for request-scoped MCP tools', () => { it('hides the internal server id and humanizes the tool name', () => { expect(getToolDisplayTitle('mcp-363de040-web_search_exa')).toBe('Web Search Exa') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 9ed7e1df6e9..b3a4c021675 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -43,6 +43,32 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): return firstStringArg(parent as Record, ...keys) } +function recordArg(args: ToolArgs, key: string): Record | undefined { + const value = args?.[key] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function stringOrNumberArg(args: ToolArgs, key: string): string { + const value = args?.[key] + return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' +} + +function deploymentTitle(args: ToolArgs, deploymentType: string): string { + return `${stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying'} ${deploymentType}` +} + +function resourceTypeLabel(type: string): string { + const labels: Record = { + knowledgebase: 'knowledge base', + scheduledtask: 'scheduled task', + file_folder: 'file folder', + log: 'logs', + } + return labels[type] ?? type +} + interface OperationDisplay { verb: string resource: string @@ -110,6 +136,20 @@ function firstOutputFilePath(args: ToolArgs): string { return '' } +function firstOutputFileMode(args: ToolArgs): string { + const outputs = args?.outputs + if (!outputs || typeof outputs !== 'object') return '' + const files = (outputs as Record).files + if (!Array.isArray(files)) return '' + + for (const file of files) { + if (!file || typeof file !== 'object') continue + const mode = stringArg(file as Record, 'mode') + if (mode) return mode + } + return '' +} + function createFileTitle(args: ToolArgs): string { const nestedArgs = args?.args && typeof args.args === 'object' ? (args.args as Record) : undefined @@ -118,8 +158,203 @@ function createFileTitle(args: ToolArgs): string { firstStringArg(args, 'fileName') || firstOutputFilePath(nestedArgs) || firstStringArg(nestedArgs, 'fileName') - if (!target) return 'Creating file' - return `Creating ${pathLeaf(target)}` + const mode = firstOutputFileMode(args) || firstOutputFileMode(nestedArgs) + const verb = mode === 'overwrite' ? 'Overwriting' : 'Creating' + if (!target) return `${verb} file` + return `${verb} ${pathLeaf(target)}` +} + +function ffmpegTitle(args: ToolArgs): string { + const titles: Record = { + overlay_audio: 'Adding audio to media', + mix_audio: 'Mixing audio', + concat: 'Combining media', + trim: 'Trimming media', + scale_pad: 'Resizing media', + overlay_image: 'Adding image to media', + add_text: 'Adding text to media', + fade: 'Adding fade to media', + extract_audio: 'Extracting audio', + convert: 'Converting media', + thumbnail: 'Creating thumbnail', + probe: 'Inspecting media', + } + return titles[stringArg(args, 'operation')] ?? 'Processing media' +} + +function knowledgeBaseTitle(args: ToolArgs): string { + const operation = stringArg(args, 'operation') + const operationArgs = recordArg(args, 'args') + const name = stringArg(operationArgs, 'name') + const tagName = stringArg(operationArgs, 'tagDisplayName') + const fileTarget = summarizeTargets( + stringArrayArg(operationArgs, 'filePaths').map(pathLeaf), + 'file' + ) + + const titles: Record = { + create: `Creating ${name || 'knowledge base'}`, + get: 'Reading knowledge base', + query: 'Searching knowledge base', + add_file: `Adding ${fileTarget} to knowledge base`, + update: 'Updating knowledge base', + delete: `Deleting ${countedResourceTarget(operationArgs, 'knowledgeBaseIds', 'knowledge base', 'knowledge bases')}`, + delete_document: `Deleting ${countedResourceTarget(operationArgs, 'documentIds', 'document', 'documents')}`, + update_document: 'Updating document', + list_tags: 'Listing knowledge base tags', + create_tag: `Creating ${tagName || 'knowledge base tag'}`, + update_tag: `Updating ${tagName || 'knowledge base tag'}`, + delete_tag: 'Deleting knowledge base tag', + get_tag_usage: 'Checking tag usage', + add_connector: 'Adding knowledge base connector', + update_connector: 'Updating knowledge base connector', + delete_connector: 'Deleting knowledge base connector', + sync_connector: 'Syncing knowledge base connector', + } + return titles[operation] ?? 'Managing knowledge base' +} + +function queryUserTableTitle(args: ToolArgs): string { + const titles: Record = { + get: 'Reading table', + get_schema: 'Reading table schema', + get_row: 'Reading table row', + query_rows: 'Querying table', + } + return titles[stringArg(args, 'operation')] ?? 'Querying table' +} + +function searchKnowledgeBaseTitle(args: ToolArgs): string { + const titles: Record = { + get: 'Reading knowledge base', + query: 'Searching knowledge base', + list_tags: 'Listing knowledge base tags', + } + return titles[stringArg(args, 'operation')] ?? 'Searching knowledge base' +} + +function userTableTitle(args: ToolArgs): string { + const operation = stringArg(args, 'operation') + const operationArgs = recordArg(args, 'args') + const name = stringArg(operationArgs, 'name') + const newName = stringArg(operationArgs, 'newName') + const columnName = stringArg(operationArgs, 'columnName') + const columnDefinitionName = nestedStringArg(operationArgs, 'column', 'name') + const columnTargets = [ + ...stringArrayArg(operationArgs, 'columnNames'), + ...(columnName ? [columnName] : []), + ] + + switch (operation) { + case 'create': + return `Creating ${name || 'table'}` + case 'create_from_file': + return 'Creating table from file' + case 'import_file': + return 'Importing file into table' + case 'get': + return 'Reading table' + case 'get_schema': + return 'Reading table schema' + case 'delete': + return `Deleting ${countedResourceTarget(operationArgs, 'tableIds', 'table', 'tables')}` + case 'rename': + return newName ? `Renaming table to ${newName}` : 'Renaming table' + case 'insert_row': + return 'Adding table row' + case 'batch_insert_rows': + return `Adding ${countedResourceTarget(operationArgs, 'rows', 'table row', 'table rows')}` + case 'get_row': + return 'Reading table row' + case 'query_rows': + return 'Querying table' + case 'update_row': + return 'Updating table row' + case 'delete_row': + return 'Deleting table row' + case 'update_rows_by_filter': + case 'batch_update_rows': + return 'Updating table rows' + case 'delete_rows_by_filter': + case 'batch_delete_rows': + return 'Deleting table rows' + case 'add_column': + return columnDefinitionName ? `Adding column ${columnDefinitionName}` : 'Adding table column' + case 'rename_column': + if (columnName && newName) return `Renaming column ${columnName} to ${newName}` + return newName ? `Renaming table column to ${newName}` : 'Renaming table column' + case 'delete_column': + return `Deleting ${summarizeTargets(columnTargets, 'table column')}` + case 'update_column': + return columnName ? `Updating column ${columnName}` : 'Updating table column' + case 'add_workflow_group': + return 'Adding table workflow' + case 'update_workflow_group': + return 'Updating table workflow' + case 'delete_workflow_group': + return 'Deleting table workflow' + case 'add_workflow_group_output': + return 'Adding workflow output column' + case 'delete_workflow_group_output': + return 'Deleting workflow output column' + case 'run_column': + return 'Running table workflow' + case 'cancel_table_runs': + return 'Cancelling table runs' + case 'list_workflow_outputs': + return 'Listing workflow outputs' + case 'list_enrichments': + return 'Listing enrichments' + case 'add_enrichment': + return `Adding ${name || 'enrichment'}` + default: + return 'Managing table' + } +} + +function materializeFileTitle(args: ToolArgs): string { + const operation = stringArg(args, 'operation') || 'save' + const targets = stringArrayArg(args, 'fileNames').map(pathLeaf) + if (operation === 'import') { + return `Importing ${summarizeTargets(targets, 'workflow')}` + } + return `Saving ${summarizeTargets(targets, 'file')}` +} + +function openResourceTitle(args: ToolArgs): string { + const resources = args?.resources + if (!Array.isArray(resources) || resources.length === 0) return 'Opening resource' + if (resources.length > 1) return `Opening ${resources.length} resources` + const resource = resources[0] + if (!resource || typeof resource !== 'object') return 'Opening resource' + const type = stringArg(resource as Record, 'type') + return `Opening ${type ? resourceTypeLabel(type) : 'resource'}` +} + +function setGlobalWorkflowVariablesTitle(args: ToolArgs): string { + const operations = args?.operations + if (!Array.isArray(operations) || operations.length === 0) return 'Setting workflow variables' + + const parsed = operations.filter( + (operation): operation is Record => + Boolean(operation) && typeof operation === 'object' && !Array.isArray(operation) + ) + const operationNames = parsed.map((operation) => stringArg(operation, 'operation')) + const firstOperation = operationNames[0] + const allSameOperation = + firstOperation && operationNames.every((operation) => operation === firstOperation) + const verbByOperation: Record = { + add: 'Adding', + edit: 'Updating', + delete: 'Deleting', + } + const verb = allSameOperation ? (verbByOperation[firstOperation] ?? 'Updating') : 'Updating' + + if (parsed.length === 1) { + const variableName = stringArg(parsed[0], 'name') + return `${verb} workflow variable${variableName ? ` ${variableName}` : ''}` + } + return `${verb} ${parsed.length} workflow variables` } /** @@ -199,7 +434,7 @@ const TOOL_TITLES: Record = { deploy_api: 'Deploying API', deploy_chat: 'Deploying chat', deploy_custom_block: 'Deploying custom block', - deploy_mcp: 'Deploying MCP server', + deploy_mcp: 'Deploying MCP tool', diff_workflows: 'Comparing workflows', download_to_workspace_file: 'Downloading file', function_execute: 'Running code', @@ -213,7 +448,7 @@ const TOOL_TITLES: Record = { get_workflow_data: 'Getting workflow data', get_workflow_run_options: 'Getting run options', list_file_folders: 'Listing folders', - list_integration_tools: 'Listing integrations', + list_integration_tools: 'Listing integration tools', list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', @@ -224,7 +459,7 @@ const TOOL_TITLES: Record = { oauth_get_auth_link: 'Getting authorization link', oauth_request_access: 'Requesting access', promote_to_live: 'Promoting to live', - redeploy: 'Redeploying', + redeploy: 'Redeploying API', rename_file: 'Renaming file', rename_file_folder: 'Renaming folder', rename_workflow: 'Renaming workflow', @@ -250,8 +485,10 @@ const TOOL_TITLES: Record = { research: 'Research Agent', scout: 'Scout Agent', search: 'Search Agent', + file: 'File Agent', media: 'Media Agent', superagent: 'Executing action', + respond: 'Gathering thoughts', context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE, } @@ -289,6 +526,67 @@ export function getToolDisplayTitle(name: string, args?: Record } switch (name) { + case 'deploy_api': + return deploymentTitle(args, 'API') + case 'deploy_chat': + return deploymentTitle(args, 'chat') + case 'deploy_custom_block': + return deploymentTitle(args, 'custom block') + case 'ffmpeg': + return ffmpegTitle(args) + case 'knowledge_base': + return knowledgeBaseTitle(args) + case 'query_user_table': + return queryUserTableTitle(args) + case 'search_knowledge_base': + return searchKnowledgeBaseTitle(args) + case 'user_table': + return userTableTitle(args) + case 'materialize_file': + return materializeFileTitle(args) + case 'open_resource': + return openResourceTitle(args) + case 'restore_resource': { + const type = stringArg(args, 'type') + return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` + } + case 'set_block_enabled': { + const enabled = args?.enabled + return typeof enabled === 'boolean' + ? `${enabled ? 'Enabling' : 'Disabling'} block` + : 'Toggling block' + } + case 'load_deployment': { + const version = stringOrNumberArg(args, 'version') + if (!version) return 'Loading deployment' + return version === 'live' + ? 'Loading live deployment' + : `Loading deployment version ${version}` + } + case 'promote_to_live': { + const version = stringOrNumberArg(args, 'version') + return version ? `Promoting version ${version} to live` : 'Promoting to live' + } + case 'update_deployment_version': { + const version = stringOrNumberArg(args, 'version') + return version ? `Updating deployment version ${version}` : 'Updating deployment' + } + case 'generate_api_key': { + const keyName = stringArg(args, 'name') + return keyName ? `Generating API key ${keyName}` : 'Generating API key' + } + case 'list_integration_tools': { + const integration = stringArg(args, 'integration') + return integration + ? `Listing ${humanizeToolName(integration)} tools` + : 'Listing integration tools' + } + case 'set_environment_variables': { + const scope = stringArg(args, 'scope') || 'workspace' + return `Setting ${scope} environment variables` + } + case 'set_global_workflow_variables': + return setGlobalWorkflowVariablesTitle(args) case 'create_file': return createFileTitle(args) case 'delete_file': { @@ -501,26 +799,38 @@ const COMPLETED_VERB_REWRITES: Record = { Accessing: 'Accessed', Adding: 'Added', Applying: 'Applied', + Cancelling: 'Cancelled', Calling: 'Called', Checking: 'Checked', + Combining: 'Combined', Comparing: 'Compared', Completing: 'Completed', + Converting: 'Converted', Crawling: 'Crawled', Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', + Disabling: 'Disabled', Downloading: 'Downloaded', + Duplicating: 'Duplicated', Editing: 'Edited', + Enabling: 'Enabled', Executing: 'Executed', + Extracting: 'Extracted', + Fading: 'Faded', Finding: 'Found', Gathering: 'Gathered', Generating: 'Generated', Getting: 'Got', + Importing: 'Imported', + Inspecting: 'Inspected', Listing: 'Listed', Loading: 'Loaded', Managing: 'Managed', + Mixing: 'Mixed', Moving: 'Moved', Opening: 'Opened', + Overwriting: 'Overwrote', Preparing: 'Prepared', Processing: 'Processed', Promoting: 'Promoted', @@ -529,14 +839,20 @@ const COMPLETED_VERB_REWRITES: Record = { Redeploying: 'Redeployed', Renaming: 'Renamed', Requesting: 'Requested', + Resizing: 'Resized', Restoring: 'Restored', Running: 'Ran', + Saving: 'Saved', Scraping: 'Scraped', Searching: 'Searched', Setting: 'Set', + Syncing: 'Synced', Toggling: 'Toggled', + Trimming: 'Trimmed', + Undeploying: 'Undeployed', Updating: 'Updated', Validating: 'Validated', + Viewing: 'Viewed', Writing: 'Wrote', } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 72cfcde085f..683a973714c 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -1,9 +1,52 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +const genericWebhookConfig = { + type: 'generic_webhook', + name: 'Webhook', + category: 'triggers', + outputs: {}, + subBlocks: [ + { id: 'webhookUrlDisplay', type: 'short-input', readOnly: true, useWebhookUrl: true }, + { id: 'requireAuth', type: 'switch' }, + ], +} + +// Mirrors an integration block (e.g. github) whose trigger-mode webhook-URL display +// fields are namespaced per trigger id and gated on selectedTriggerId. +const multiTriggerConfig = { + type: 'github_v2', + name: 'GitHub', + category: 'tools', + outputs: {}, + subBlocks: [ + { + id: 'webhookUrlDisplay_github_push', + type: 'short-input', + readOnly: true, + useWebhookUrl: true, + condition: { field: 'selectedTriggerId', value: 'github_push' }, + }, + ], +} + +vi.mock('@/blocks/registry', () => ({ + getBlock: (type: string) => + type === 'generic_webhook' + ? genericWebhookConfig + : type === 'github_v2' + ? multiTriggerConfig + : undefined, +})) /** * Builds a minimal one-block workflow whose knowledge block carries the two @@ -65,3 +108,102 @@ describe('sanitizeForCopilot knowledge tag subblocks', () => { expect(inputs).not.toHaveProperty('tagFilters') }) }) + +/** Builds a one-block workflow for webhook-URL synthesis tests. */ +function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { + return { + blocks: { [blockId]: { id: blockId, position: { x: 0, y: 0 }, outputs: {}, ...block } }, + edges: [], + loops: {}, + parallels: {}, + } as unknown as WorkflowState +} + +describe('sanitizeForCopilot webhook trigger URL', () => { + // Regression: the webhook URL only existed as a UI-computed display field, so the + // copilot could not tell users where to point their external service. + it('synthesizes the read-only webhook URL from the block id for a generic webhook trigger', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('hook-1', { + type: 'generic_webhook', + name: 'Webhook 1', + enabled: true, + subBlocks: { requireAuth: { id: 'requireAuth', type: 'switch', value: true } }, + }) + ) + + expect(result.blocks['hook-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( + 'https://sim.test/api/webhooks/trigger/hook-1' + ) + }) + + it('prefers the stored triggerPath over the block id', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('hook-1', { + type: 'generic_webhook', + name: 'Webhook 1', + enabled: true, + subBlocks: { triggerPath: { id: 'triggerPath', type: 'short-input', value: 'my-path' } }, + }) + ) + + expect(result.blocks['hook-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( + 'https://sim.test/api/webhooks/trigger/my-path' + ) + }) + + it('does not synthesize a URL for non-trigger blocks', () => { + const result = sanitizeForCopilot(makeKnowledgeWorkflow(null)) + + expect(result.blocks['kb-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) + + it('synthesizes a URL for an integration block whose selected trigger is webhook-based', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('gh-1', { + type: 'github_v2', + name: 'GitHub 1', + enabled: true, + triggerMode: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_push' }, + }, + }) + ) + + expect(result.blocks['gh-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( + 'https://sim.test/api/webhooks/trigger/gh-1' + ) + }) + + it('omits the URL when the selected trigger has no webhook-URL field', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('gh-1', { + type: 'github_v2', + name: 'GitHub 1', + enabled: true, + triggerMode: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_poller' }, + }, + }) + ) + + expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) + + it('omits the URL when the integration block is not in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('gh-1', { + type: 'github_v2', + name: 'GitHub 1', + enabled: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_push' }, + }, + }) + ) + + expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 1879fae0273..2d4f09bba40 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -1,8 +1,15 @@ import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object' import type { Edge } from 'reactflow' +import { getBaseUrl } from '@/lib/core/utils/urls' import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' +import { + buildSubBlockValues, + evaluateSubBlockCondition, +} from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' /** * Sanitized workflow state for copilot (removes all UI-specific data) @@ -323,6 +330,45 @@ function sanitizeSubBlocks( return sanitized } +/** + * Resolves the public webhook URL for a block acting as a webhook trigger, or null + * for any other block. Mirrors the UI derivation (`useWebhookManagement`): + * `{baseUrl}/api/webhooks/trigger/{triggerPath || blockId}`. + * + * The webhook URL only ever exists as a UI-computed display field + * (`webhookUrlDisplay`, never persisted), which left the copilot unable to tell + * users where to point their external service. This surfaces it in the copilot's + * read view as the read-only {@link TRIGGER_WEBHOOK_URL_FIELD} input — derived at + * read time, never stored, and rejected on write by `edit_workflow` validation. + */ +function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | null { + const blockConfig = getBlock(block.type) + if (!blockConfig) return null + + const actsAsTrigger = blockConfig.category === 'triggers' || block.triggerMode === true + if (!actsAsTrigger) return null + + // A webhook-URL display subblock (`useWebhookUrl`) marks a webhook-based trigger. + // Multi-trigger blocks namespace one per trigger id, each gated by a condition on + // selectedTriggerId — only count a field active for the current values, so a block + // configured with a polling trigger doesn't advertise a webhook URL. + const values = buildSubBlockValues(block.subBlocks || {}) + const hasActiveWebhookUrlField = blockConfig.subBlocks.some( + (sb) => sb.useWebhookUrl === true && evaluateSubBlockCondition(sb.condition, values) + ) + if (!hasActiveWebhookUrlField) return null + + const triggerPath = block.subBlocks?.triggerPath?.value + const path = typeof triggerPath === 'string' && triggerPath.length > 0 ? triggerPath : blockId + try { + return `${getBaseUrl()}/api/webhooks/trigger/${path}` + } catch { + // getBaseUrl throws when NEXT_PUBLIC_APP_URL is unset; omit the field rather + // than fail the whole state read. + return null + } +} + /** * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) * Uses 0-indexed numbering for else-if conditions @@ -524,6 +570,11 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { } else { // For regular blocks, sanitize subBlocks inputs = sanitizeSubBlocks(block.subBlocks) + + const webhookUrl = resolveTriggerWebhookUrl(blockId, block) + if (webhookUrl) { + inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl + } } // Check if this is a loop or parallel (has children) diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index bd1ca98de0d..d7126c12b8e 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -30,6 +30,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ 'triggerId', ] +/** + * Synthesized read-only field exposing a webhook trigger block's public URL in the + * copilot's read view of workflow state (see sanitizeForCopilot). The URL is derived + * at read time — it is never persisted — and edit_workflow rejects writes to it. + * + * Deliberately NOT 'webhookUrl': that id is a real user-editable subblock on some + * action blocks (e.g. Vercel's create_webhook target URL). + */ +export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl' + /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. * This prevents runaway errors from continuously executing failing workflows. From a6a3004fb6194e29a8eeaa93678ff510c6643087 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Jul 2026 13:16:37 -0700 Subject: [PATCH 03/10] fix(mship): tool name audit --- .../agent-group/tool-call-item.test.tsx | 9 ++ .../components/agent-group/tool-call-item.tsx | 9 +- .../message-content/message-content.test.ts | 140 ++++++++++++++++++ .../message-content/message-content.tsx | 7 +- .../generic-resource-content.test.tsx | 29 ++++ .../generic-resource-content.tsx | 3 +- .../copilot/tools/client/store-utils.test.ts | 5 + .../lib/copilot/tools/client/store-utils.ts | 2 +- apps/sim/lib/copilot/tools/descriptions.ts | 11 +- .../lib/copilot/tools/tool-display.test.ts | 53 +++++++ apps/sim/lib/copilot/tools/tool-display.ts | 10 ++ apps/sim/lib/copilot/vfs/serializers.ts | 101 +++++++++++-- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 29 +++- .../hosted-key/hosted-key-rate-limiter.ts | 12 +- .../sim/lib/workflows/subblocks/visibility.ts | 8 +- 15 files changed, 390 insertions(+), 38 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx index 2fa6cb4c3ee..b432e67efc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx @@ -41,6 +41,15 @@ describe('ToolCallItem', () => { expect(markup).not.toContain('Writing brief.md') }) + it('defensively applies the completed verb for every successful tool row', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Compared workflows') + expect(markup).not.toContain('Comparing workflows') + }) + it('renders the owning integration icon for a resolved integration operation', () => { vi.mocked(getBlockByToolName).mockReturnValueOnce({ name: 'Gmail', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 76be63466f6..2b94fcff9b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -7,7 +7,7 @@ import { } from '@/lib/copilot/generated/tool-catalog-v1' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display' +import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' import { getBareIconStyle } from '@/blocks/icon-color' import { getBlockByToolName } from '@/blocks/registry' import type { ToolCallStatus } from '../../../../types' @@ -44,6 +44,8 @@ interface ToolCallItemProps { * rewrite in `toToolData`, the past-tense flip is applied here on success. * A `read` of a block or integration schema shows the block's brand icon * inline next to its display name (e.g. the Gmail logo before "Read Gmail"). + * The status-aware rewrite is repeated at this final rendering boundary so + * live, replayed, and directly-constructed rows cannot bypass completed verbs. */ export function ToolCallItem({ toolName, @@ -98,10 +100,7 @@ export function ToolCallItem({ const isExecuting = resolveToolDisplayState(status) === 'spinner' const liveTitle = liveWorkspaceFileTitle || displayTitle - const title = - status === 'success' && liveWorkspaceFileTitle - ? (getToolCompletedTitle(liveTitle) ?? liveTitle) - : liveTitle + const title = getToolStatusDisplayTitle(liveTitle, status) const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index bc3df638f25..30dd7c75943 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -2,6 +2,15 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { + createTurnModel, + reduceEvent, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model' +import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize' import type { ContentBlock } from '../../types' import { assistantMessageHasVisibleExecutingTool, @@ -36,6 +45,49 @@ function mainToolCall(id: string, name: string): ContentBlock { return { type: 'tool_call', toolCall: { id, name, status: 'success' }, timestamp: 1 } } +function representativeToolArgs(entry: ToolCatalogEntry): Record { + const args: Record = {} + if (!entry.parameters || typeof entry.parameters !== 'object') return args + const properties = (entry.parameters as { properties?: unknown }).properties + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args + + for (const [key, rawSchema] of Object.entries(properties)) { + if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue + const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown } + if (schema.default !== undefined) { + args[key] = schema.default + } else if (Array.isArray(schema.enum) && schema.enum.length > 0) { + args[key] = schema.enum[0] + } else if (schema.type === 'boolean') { + args[key] = true + } else if (schema.type === 'object') { + args[key] = {} + } + } + return args +} + +function toolEnvelope( + seq: number, + payload: Record, + agentId = 'deploy' +): PersistedStreamEventEnvelope { + return { + v: 1, + seq, + ts: new Date(seq).toISOString(), + stream: { streamId: 'stream-1', cursor: String(seq) }, + type: 'tool', + payload, + scope: { + lane: 'subagent', + spanId: `${agentId}-span`, + parentSpanId: 'main', + agentId, + }, + } as PersistedStreamEventEnvelope +} + describe('parseBlocks span-identity tree', () => { it('refines a completed credential rename with its previous and new names', () => { const segments = parseBlocks([ @@ -388,6 +440,94 @@ describe('completed tool titles', () => { ) }) + it('renders the completed deployment action and deployment type', () => { + expect( + firstToolTitle([ + { + type: 'tool_call', + toolCall: { + id: 'undeploy-api', + name: 'deploy_api', + status: 'success', + params: { action: 'undeploy' }, + }, + timestamp: 1, + }, + ]) + ).toBe('Undeployed API') + + expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_mcp')])).toBe('Deployed MCP tool') + }) + + it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => { + const model = createTurnModel() + reduceEvent( + model, + toolEnvelope(1, { + phase: 'call', + toolCallId: 'diff-1', + toolName: 'diff_workflows', + arguments: { ref1: 'live', ref2: 'draft' }, + }) + ) + reduceEvent( + model, + toolEnvelope(2, { + phase: 'result', + toolCallId: 'diff-1', + toolName: 'diff_workflows', + success: true, + status: 'success', + output: { differences: [] }, + }) + ) + + expect(firstToolTitle(modelToContentBlocks(model))).toBe('Compared workflows') + }) + + it('renders the completed title through the full wire lifecycle for every visible tool', () => { + const hiddenToolNames = getHiddenToolNames() + const failures: string[] = [] + + for (const [toolName, entry] of Object.entries(TOOL_CATALOG)) { + // Internal subagent dispatches become agent groups, and hidden plumbing + // is intentionally suppressed; neither produces a visible tool row. + if (entry.internal || hiddenToolNames.has(toolName)) continue + + const args = representativeToolArgs(entry) + const model = createTurnModel() + reduceEvent( + model, + toolEnvelope(1, { + phase: 'call', + toolCallId: `${toolName}-1`, + toolName, + arguments: args, + }) + ) + reduceEvent( + model, + toolEnvelope(2, { + phase: 'result', + toolCallId: `${toolName}-1`, + toolName, + success: true, + status: 'success', + output: {}, + }) + ) + + const presentTitle = getToolDisplayTitle(toolName, args) + const expectedTitle = getToolStatusDisplayTitle(presentTitle, 'success') + const actualTitle = firstToolTitle(modelToContentBlocks(model)) + if (actualTitle !== expectedTitle) { + failures.push(`${toolName}: expected ${expectedTitle}, received ${actualTitle}`) + } + } + + expect(failures).toEqual([]) + }) + it('keeps present tense while executing and on error', () => { expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') 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 3d3d0be2458..aba820ab8dc 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 @@ -6,8 +6,8 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' import { - getToolCompletedTitle, getToolDisplayTitle, + getToolStatusDisplayTitle, humanizeToolName, } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -169,10 +169,7 @@ function toToolData(tc: NonNullable): ToolCallData { const overrideDisplayTitle = getOverrideDisplayTitle(tc) const resolvedTitle = overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) - const displayTitle = - tc.status === 'success' - ? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle) - : resolvedTitle + const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status) return { id: tc.id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.test.tsx new file mode 100644 index 00000000000..f73399b7834 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.test.tsx @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { GenericResourceContent } from './generic-resource-content' + +describe('GenericResourceContent', () => { + it('renders the completed verb for a successful tool result', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Compared workflows') + expect(markup).not.toContain('Comparing workflows') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx index e4616e5fb99..ad59b80d212 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { PillsRing } from '@sim/emcn' +import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { GenericResourceData } from '@/app/workspace/[workspaceId]/home/types' interface GenericResourceContentProps { @@ -40,7 +41,7 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) { /> )} - {entry.displayTitle} + {getToolStatusDisplayTitle(entry.displayTitle, entry.status)} {entry.status === 'error' && ( Error diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 2fd6e54bcc6..39c225deaf3 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -25,6 +25,11 @@ describe('resolveToolDisplay', () => { }) it('formats read targets from workspace paths', () => { + expect(resolveToolDisplay(ReadTool.id, ClientToolCallState.executing)?.text).toBe( + 'Reading file' + ) + expect(resolveToolDisplay(ReadTool.id, ClientToolCallState.success)?.text).toBe('Read file') + expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { path: 'files/report.pdf', diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index f143602dcd2..24f0d7e7cc8 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -68,7 +68,7 @@ function readStringParam( } function formatReadingLabel(target: string | undefined, state: ClientToolCallState): string { - const suffix = target ? ` ${target}` : '' + const suffix = ` ${target || 'file'}` switch (state) { case ClientToolCallState.success: return `Read${suffix}` diff --git a/apps/sim/lib/copilot/tools/descriptions.ts b/apps/sim/lib/copilot/tools/descriptions.ts index c315884a572..0defd9013c3 100644 --- a/apps/sim/lib/copilot/tools/descriptions.ts +++ b/apps/sim/lib/copilot/tools/descriptions.ts @@ -1,6 +1,8 @@ import type { ToolConfig } from '@/tools/types' const HOSTED_API_KEY_NOTE = 'API key is hosted by Sim.' +const CONDITIONAL_HOSTED_API_KEY_NOTE = + 'API key is hosted by Sim when hosted-key support applies to the selected configuration.' const EMAIL_TAGLINE_NOTE = 'Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.' const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send']) @@ -16,8 +18,13 @@ export function getCopilotToolDescription( const baseDescription = tool.description || tool.name || options?.fallbackName || '' const notes: string[] = [] - if (options?.isHosted && tool.hosting && !baseDescription.includes(HOSTED_API_KEY_NOTE)) { - notes.push(HOSTED_API_KEY_NOTE) + if ( + options?.isHosted && + tool.hosting && + !baseDescription.includes(HOSTED_API_KEY_NOTE) && + !baseDescription.includes(CONDITIONAL_HOSTED_API_KEY_NOTE) + ) { + notes.push(tool.hosting.enabled ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE) } if ( diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 5b016fad581..4320f7f5b3d 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -16,6 +16,7 @@ import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' import { getToolCompletedTitle, getToolDisplayTitle, + getToolStatusDisplayTitle, humanizeToolName, mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' @@ -42,6 +43,16 @@ function representativeToolArgs(entry: ToolCatalogEntry): Record)[property] + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [] + const values = (schema as { enum?: unknown }).enum + return Array.isArray(values) ? values : [] +} + describe('humanizeToolName', () => { it('title-cases snake_case names', () => { expect(humanizeToolName('manage_folder')).toBe('Manage Folder') @@ -88,6 +99,40 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(missingCompletedVerbs).toEqual([]) }) + + it('resolves every catalog action and operation enum without a generic placeholder', () => { + const genericPlaceholders = new Set([ + 'Credential action', + 'Custom tool action', + 'Editing file', + 'Folder action', + 'MCP server action', + 'Managing knowledge base', + 'Managing table', + 'Preparing file', + 'Processing media', + 'Scheduled task action', + 'Skill action', + ]) + const unresolvedVariants: string[] = [] + + for (const [name, entry] of Object.entries(TOOL_CATALOG)) { + for (const property of ['action', 'operation']) { + for (const value of toolPropertyEnum(entry, property)) { + const title = getToolDisplayTitle(name, { + ...representativeToolArgs(entry), + [property]: value, + title: 'resource', + }) + if (genericPlaceholders.has(title) || !getToolCompletedTitle(title)) { + unresolvedVariants.push(`${name}.${property}=${String(value)}: ${title}`) + } + } + } + } + + expect(unresolvedVariants).toEqual([]) + }) }) describe('getToolDisplayTitle for deployments', () => { @@ -129,6 +174,14 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Folder action')).toBeUndefined() expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) + + it('projects completed titles only for successful rows', () => { + expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows') + expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe( + 'Comparing workflows' + ) + expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows') + }) }) describe('mvDisplayVerb', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index b3a4c021675..cae5e8b4e60 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -872,3 +872,13 @@ export function getToolCompletedTitle(title: string): string | undefined { if (!past) return undefined return past + title.slice(firstWord.length) } + +/** + * Resolve the final title for a tool status at a rendering boundary. Persisted + * and live snapshots intentionally keep the present-tense activity title so a + * running/error row remains truthful; every successful renderer calls this to + * project the corresponding completed title from the canonical verb map. + */ +export function getToolStatusDisplayTitle(title: string, status: string): string { + return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title +} diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index ae7a971a794..2f7d28c8326 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -7,6 +7,51 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' import type { ToolConfig } from '@/tools/types' +export type VfsToolAuth = + | { + type: 'oauth' + required: boolean + provider: string + } + | { + type: 'api_key' + param: string + mode: 'hosted_or_byok' | 'conditional_hosted_or_byok' | 'byok_required' + provider?: string + } + +export interface ComponentSerializationOptions { + hosted?: boolean + toolConfigs?: ReadonlyMap +} + +/** + * Project runtime tool authentication into a stable, machine-readable VFS contract. + * ToolConfig.hosting remains the source of truth for every hosted-key integration. + */ +export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined { + if (tool.oauth) { + return { + type: 'oauth', + required: tool.oauth.required, + provider: tool.oauth.provider, + } + } + + if (!tool.hosting) return undefined + + return { + type: 'api_key', + param: tool.hosting.apiKeyParam, + mode: hosted + ? tool.hosting.enabled + ? 'conditional_hosted_or_byok' + : 'hosted_or_byok' + : 'byok_required', + provider: tool.hosting.byokProviderId, + } +} + /** * Serialize workflow metadata for VFS meta.json. * @@ -427,6 +472,8 @@ function serializeSubBlock(sb: SubBlockConfig): Record { if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue if (sb.mode) result.mode = sb.mode if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId + if (sb.condition && typeof sb.condition !== 'function') result.condition = sb.condition + if (sb.dependsOn) result.dependsOn = sb.dependsOn // Include static options arrays for dropdowns if (Array.isArray(sb.options)) { @@ -439,28 +486,43 @@ function serializeSubBlock(sb: SubBlockConfig): Record { /** * Serialize a block schema for VFS components/blocks/{type}.json */ -export function serializeBlockSchema(block: BlockConfig): string { +export function serializeBlockSchema( + block: BlockConfig, + options?: ComponentSerializationOptions +): string { // Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks; // treat `hidden` as hidden for them so those never reach the agent's schema. const customBlock = isCustomBlockType(block.type) + const hosted = options?.hosted ?? isHosted + const visibleSubBlocks = block.subBlocks.filter( + (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) + ) + const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden)) + .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) .map((sb) => sb.id) + .filter((id) => !visibleIds.has(id)) ) - const subBlocks = block.subBlocks - .filter((sb) => !hiddenIds.has(sb.id)) - .map((sb) => { - const serialized = serializeSubBlock(sb) + const subBlocks = visibleSubBlocks.map((sb) => { + const serialized = serializeSubBlock(sb) - if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { - serialized.options = getStaticModelOptionsForVFS() - serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE - } + if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { + serialized.options = getStaticModelOptionsForVFS() + serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE + } - return serialized - }) + return serialized + }) + + const toolAuth: Record = {} + for (const toolId of block.tools.access) { + const tool = options?.toolConfigs?.get(toolId) + if (!tool) continue + const auth = serializeToolAuth(tool, hosted) + if (auth) toolAuth[toolId] = auth + } const inputs = block.inputs && hiddenIds.size > 0 @@ -477,12 +539,14 @@ export function serializeBlockSchema(block: BlockConfig): string { bestPractices: block.bestPractices || undefined, triggerAllowed: block.triggerAllowed || undefined, singleInstance: block.singleInstance || undefined, + authMode: block.authMode || undefined, // Custom (deploy-as-block) blocks execute via a baked `workflow_executor` // internally; that's implementation plumbing, not something the agent // configures. Hiding it keeps the block self-contained (fields in, outputs // out) so the agent doesn't treat it like the generic workflow block and // ask for a workflowId/inputMapping. tools: isCustomBlockType(block.type) ? [] : block.tools.access, + toolAuth: Object.keys(toolAuth).length > 0 ? toolAuth : undefined, subBlocks, inputs, outputs: Object.fromEntries( @@ -758,8 +822,14 @@ export function serializeSkill(s: { /** * Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json */ -export function serializeIntegrationSchema(tool: ToolConfig): string { - const hostedApiKeyParam = isHosted && tool.hosting ? tool.hosting.apiKeyParam : null +export function serializeIntegrationSchema( + tool: ToolConfig, + options?: Pick +): string { + const hosted = options?.hosted ?? isHosted + const auth = serializeToolAuth(tool, hosted) + const hostedApiKeyParam = + auth?.type === 'api_key' && auth.mode === 'hosted_or_byok' ? auth.param : null return JSON.stringify( { @@ -768,8 +838,9 @@ export function serializeIntegrationSchema(tool: ToolConfig): string { // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, - description: getCopilotToolDescription(tool, { isHosted }), + description: getCopilotToolDescription(tool, { isHosted: hosted }), version: tool.version, + auth, oauth: tool.oauth ? { required: tool.oauth.required, provider: tool.oauth.provider } : undefined, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index d83c7fecb17..0567cb2832c 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -92,7 +92,7 @@ import { workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' -import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, @@ -133,6 +133,7 @@ import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { ToolConfig } from '@/tools/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') @@ -227,23 +228,37 @@ function getStaticComponentFiles(): Map { // files (overviews, oauth/api-key summaries) that all viewers receive. const allBlocks = Object.values(BLOCK_REGISTRY) const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar) + const exposedTools = getExposedIntegrationTools() + const toolConfigs = new Map() + for (const { toolId, config } of exposedTools) { + toolConfigs.set(toolId, config) + toolConfigs.set(config.id, config) + } let blocksFiltered = 0 for (const block of visibleBlocks) { const path = `components/blocks/${block.type}.json` - files.set(path, serializeBlockSchema(block)) + files.set(path, serializeBlockSchema(block, { toolConfigs })) } blocksFiltered = allBlocks.length - visibleBlocks.length let integrationCount = 0 const oauthServices = new Map() - const apiKeyServices = new Map() + const apiKeyServices = new Map< + string, + { + params: string[] + operations: string[] + hostedOperations: string[] + conditionalHostedOperations: string[] + } + >() // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the // deferred callable tools — so discovery and execution can never drift. - for (const exposedTool of getExposedIntegrationTools()) { + for (const exposedTool of exposedTools) { const { config: tool, service, operation, blockType, preview } = exposedTool const path = `components/integrations/${service}/${operation}.json` files.set(path, serializeIntegrationSchema(tool)) @@ -263,15 +278,21 @@ function getStaticComponentFiles(): Map { } } else if (tool.hosting?.apiKeyParam) { const existing = apiKeyServices.get(service) + const hostedOperation = isHosted && !tool.hosting.enabled + const conditionalHostedOperation = isHosted && Boolean(tool.hosting.enabled) if (existing) { if (!existing.params.includes(tool.hosting.apiKeyParam)) { existing.params.push(tool.hosting.apiKeyParam) } existing.operations.push(operation) + if (hostedOperation) existing.hostedOperations.push(operation) + if (conditionalHostedOperation) existing.conditionalHostedOperations.push(operation) } else { apiKeyServices.set(service, { params: [tool.hosting.apiKeyParam], operations: [operation], + hostedOperations: hostedOperation ? [operation] : [], + conditionalHostedOperations: conditionalHostedOperation ? [operation] : [], }) } } diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts index d199ed9e2c2..a40fff6176c 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts @@ -79,11 +79,17 @@ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise { } /** - * Resolves env var names for a numbered key prefix using a `{PREFIX}_COUNT` env var. - * E.g. with `EXA_API_KEY_COUNT=5`, returns `['EXA_API_KEY_1', ..., 'EXA_API_KEY_5']`. + * Resolves env var names for a hosted-key prefix. Numbered pools use a + * `{PREFIX}_COUNT` env var. Deployments that still provide one legacy singular + * `{PREFIX}` key remain supported when no count is configured. */ function resolveEnvKeys(prefix: string): string[] { - const count = Number.parseInt(process.env[`${prefix}_COUNT`] || '0', 10) + const countValue = process.env[`${prefix}_COUNT`] + if (countValue === undefined) { + return process.env[prefix] ? [prefix] : [] + } + + const count = Number.parseInt(countValue, 10) const names: string[] = [] for (let i = 1; i <= count; i++) { names.push(`${prefix}_${i}`) diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 88599d51471..b64fc025a2b 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -533,8 +533,12 @@ export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean { * - `hideWhenEnvSet`: hidden when a specific NEXT_PUBLIC_ env var is truthy * (credential fields hidden when the deployment provides them server-side) */ -export function isSubBlockHidden(subBlock: SubBlockConfig): boolean { - if (subBlock.hideWhenHosted && isHosted) return true +export function isSubBlockHidden( + subBlock: SubBlockConfig, + options?: { hosted?: boolean } +): boolean { + const hosted = options?.hosted ?? isHosted + if (subBlock.hideWhenHosted && hosted) return true if (subBlock.hideWhenEnvSet && isTruthy(getEnv(subBlock.hideWhenEnvSet))) return true return false } From 6549ddcb00ab0727c069e6dbedad17cde75a50ca Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Jul 2026 13:31:29 -0700 Subject: [PATCH 04/10] fix(mship): hosted key data --- .../lib/copilot/tools/descriptions.test.ts | 16 ++ apps/sim/lib/copilot/vfs/serializers.test.ts | 149 +++++++++++++++++- apps/sim/lib/copilot/vfs/serializers.ts | 53 ++++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 31 +--- .../hosted-key-rate-limiter.test.ts | 24 +++ .../hosted-key/hosted-key-rate-limiter.ts | 3 +- apps/sim/tools/hosting.ts | 16 ++ apps/sim/tools/image/generate.ts | 7 +- apps/sim/tools/types.ts | 25 ++- 9 files changed, 289 insertions(+), 35 deletions(-) create mode 100644 apps/sim/tools/hosting.ts diff --git a/apps/sim/lib/copilot/tools/descriptions.test.ts b/apps/sim/lib/copilot/tools/descriptions.test.ts index 6bb7d11d037..ef5e3a3871b 100644 --- a/apps/sim/lib/copilot/tools/descriptions.test.ts +++ b/apps/sim/lib/copilot/tools/descriptions.test.ts @@ -30,6 +30,22 @@ describe('getCopilotToolDescription', () => { ).toBe('Search for brands by company name API key is hosted by Sim.') }) + it.concurrent('does not claim unconditional hosting for a conditional hosted tool', () => { + expect( + getCopilotToolDescription( + { + id: 'image_generate', + name: 'Image Generate', + description: 'Generate an image', + hosting: { apiKeyParam: 'apiKey', enabled: () => true } as never, + }, + { isHosted: true } + ) + ).toBe( + 'Generate an image API key is hosted by Sim when hosted-key support applies to the selected configuration.' + ) + }) + it.concurrent('uses the fallback name when no description exists', () => { expect( getCopilotToolDescription( diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 8b6de1abddc..a31d63e3dc8 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -2,7 +2,45 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { serializeFileMeta, serializeKBMeta, serializeTableMeta } from './serializers' +import type { BlockConfig } from '@/blocks/types' +import { hostedKeyEnabledWhen } from '@/tools/hosting' +import type { ToolConfig } from '@/tools/types' +import { + serializeApiKeyIntegrations, + serializeBlockSchema, + serializeFileMeta, + serializeIntegrationSchema, + serializeKBMeta, + serializeTableMeta, +} from './serializers' + +function hostedTool(id: string, conditional = false): ToolConfig { + return { + id, + name: id, + description: `Run ${id}`, + version: '1.0.0', + params: { + provider: { type: 'string', required: conditional }, + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + }, + request: { + url: 'https://example.com', + method: 'POST', + headers: () => ({}), + }, + hosting: { + enabled: conditional + ? hostedKeyEnabledWhen({ field: 'provider', operator: 'equals', value: 'hosted' }) + : undefined, + envKeyPrefix: 'EXAMPLE_API_KEY', + apiKeyParam: 'apiKey', + byokProviderId: 'exa', + pricing: { type: 'per_request', cost: 0.01 }, + rateLimit: { mode: 'per_request', requestsPerMinute: 10 }, + }, + } +} describe('VFS metadata serializers', () => { it('includes the authoritative file update timestamp', () => { @@ -50,6 +88,115 @@ describe('VFS metadata serializers', () => { }) }) +describe('hosted-key VFS metadata', () => { + it('indexes hosted and conditional-hosted operations for every configured service', () => { + const metadata = JSON.parse( + serializeApiKeyIntegrations( + [ + { config: hostedTool('search'), service: 'generic_search', operation: 'search' }, + { + config: hostedTool('generate', true), + service: 'generic_search', + operation: 'generate', + }, + ], + true + ) + ) + + expect(metadata.generic_search).toEqual({ + params: ['apiKey'], + operations: ['search', 'generate'], + hostedOperations: ['search'], + conditionalHostedOperations: ['generate'], + }) + }) + + it('marks an operation as hosted and omits only its managed API-key param', () => { + const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: true })) + + expect(schema.auth).toEqual({ + type: 'api_key', + param: 'apiKey', + mode: 'hosted_or_byok', + provider: 'exa', + }) + expect(schema.params).not.toHaveProperty('apiKey') + }) + + it('keeps the API-key param and publishes the exact condition for conditional hosting', () => { + const schema = JSON.parse( + serializeIntegrationSchema(hostedTool('generate', true), { hosted: true }) + ) + + expect(schema.auth).toEqual({ + type: 'api_key', + param: 'apiKey', + mode: 'conditional_hosted_or_byok', + provider: 'exa', + condition: { field: 'provider', operator: 'equals', value: 'hosted' }, + }) + expect(schema.params.apiKey).toBeDefined() + }) + + it('marks the same operation as BYOK-required outside hosted Sim', () => { + const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: false })) + + expect(schema.auth.mode).toBe('byok_required') + expect(schema.params.apiKey).toBeDefined() + }) + + it('preserves a visible duplicate API-key field for mixed-operation blocks', () => { + const block = { + type: 'mixed_search', + name: 'Mixed Search', + description: 'Search or research', + category: 'tools', + bgColor: '#000000', + icon: () => null, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Hosted search', id: 'search' }, + { label: 'Research with BYOK', id: 'research' }, + ], + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + hideWhenHosted: true, + condition: { field: 'operation', value: 'search' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + condition: { field: 'operation', value: 'research' }, + }, + ], + tools: { access: ['search'] }, + inputs: { operation: { type: 'string' }, apiKey: { type: 'string' } }, + outputs: {}, + } as unknown as BlockConfig + const schema = JSON.parse( + serializeBlockSchema(block, { + hosted: true, + toolConfigs: new Map([['search', hostedTool('search')]]), + }) + ) + + expect(schema.subBlocks.filter((subBlock: { id: string }) => subBlock.id === 'apiKey')).toEqual( + [expect.objectContaining({ condition: { field: 'operation', value: 'research' } })] + ) + expect(schema.inputs.apiKey).toBeDefined() + expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') + }) +}) + describe('serializeKBMeta', () => { const baseKb = { id: 'kb-1', diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 2f7d28c8326..ad60e73225e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -5,7 +5,7 @@ import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' -import type { ToolConfig } from '@/tools/types' +import type { ToolConfig, ToolHostingCondition } from '@/tools/types' export type VfsToolAuth = | { @@ -18,6 +18,7 @@ export type VfsToolAuth = param: string mode: 'hosted_or_byok' | 'conditional_hosted_or_byok' | 'byok_required' provider?: string + condition?: ToolHostingCondition } export interface ComponentSerializationOptions { @@ -49,6 +50,7 @@ export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolA : 'hosted_or_byok' : 'byok_required', provider: tool.hosting.byokProviderId, + condition: hosted ? tool.hosting.enabled?.condition : undefined, } } @@ -621,6 +623,55 @@ export function serializeApiKeys( ) } +interface ApiKeyIntegrationTool { + config: ToolConfig + service: string + operation: string + preview?: boolean +} + +/** + * Serialize API-key integration discovery with operation-level hosted status. + * ToolConfig.hosting is the only provider registry used to build this index. + */ +export function serializeApiKeyIntegrations( + tools: ApiKeyIntegrationTool[], + hosted = isHosted +): string { + const services = new Map< + string, + { + params: string[] + operations: string[] + hostedOperations: string[] + conditionalHostedOperations: string[] + } + >() + + for (const { config: tool, service, operation, preview } of tools) { + if (preview || !tool.hosting?.apiKeyParam) continue + + const metadata = services.get(service) ?? { + params: [], + operations: [], + hostedOperations: [], + conditionalHostedOperations: [], + } + if (!metadata.params.includes(tool.hosting.apiKeyParam)) { + metadata.params.push(tool.hosting.apiKeyParam) + } + metadata.operations.push(operation) + if (hosted && tool.hosting.enabled) { + metadata.conditionalHostedOperations.push(operation) + } else if (hosted) { + metadata.hostedOperations.push(operation) + } + services.set(service, metadata) + } + + return JSON.stringify(Object.fromEntries(services), null, 2) +} + /** * Serialize environment variables for VFS environment/variables.json. * Shows variable NAMES only — NOT values. diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 0567cb2832c..fbc98d362f5 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -53,6 +53,7 @@ import { } from '@/lib/copilot/vfs/path-utils' import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers' import { + serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, serializeBuiltinTriggerSchema, @@ -245,15 +246,6 @@ function getStaticComponentFiles(): Map { let integrationCount = 0 const oauthServices = new Map() - const apiKeyServices = new Map< - string, - { - params: string[] - operations: string[] - hostedOperations: string[] - conditionalHostedOperations: string[] - } - >() // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the @@ -276,25 +268,6 @@ function getStaticComponentFiles(): Map { } else { oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] }) } - } else if (tool.hosting?.apiKeyParam) { - const existing = apiKeyServices.get(service) - const hostedOperation = isHosted && !tool.hosting.enabled - const conditionalHostedOperation = isHosted && Boolean(tool.hosting.enabled) - if (existing) { - if (!existing.params.includes(tool.hosting.apiKeyParam)) { - existing.params.push(tool.hosting.apiKeyParam) - } - existing.operations.push(operation) - if (hostedOperation) existing.hostedOperations.push(operation) - if (conditionalHostedOperation) existing.conditionalHostedOperations.push(operation) - } else { - apiKeyServices.set(service, { - params: [tool.hosting.apiKeyParam], - operations: [operation], - hostedOperations: hostedOperation ? [operation] : [], - conditionalHostedOperations: conditionalHostedOperation ? [operation] : [], - }) - } } } @@ -304,7 +277,7 @@ function getStaticComponentFiles(): Map { ) files.set( 'environment/api-key-integrations.json', - JSON.stringify(Object.fromEntries(apiKeyServices), null, 2) + serializeApiKeyIntegrations(exposedTools, isHosted) ) files.set( diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts index 1d3b3b10cb9..9676a30f195 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts @@ -71,6 +71,7 @@ describe('HostedKeyRateLimiter', () => { process.env.EXA_API_KEY_1 = 'test-key-1' process.env.EXA_API_KEY_2 = 'test-key-2' process.env.EXA_API_KEY_3 = 'test-key-3' + process.env.EXA_API_KEY = undefined }) afterEach(() => { @@ -87,6 +88,7 @@ describe('HostedKeyRateLimiter', () => { mockAdapter.consumeTokens.mockResolvedValue(allowedResult) process.env.EXA_API_KEY_COUNT = undefined + process.env.EXA_API_KEY = undefined process.env.EXA_API_KEY_1 = undefined process.env.EXA_API_KEY_2 = undefined process.env.EXA_API_KEY_3 = undefined @@ -102,6 +104,28 @@ describe('HostedKeyRateLimiter', () => { expect(result.error).toContain('No hosted keys configured') }) + it('uses a singular hosted key when no numbered pool count is configured', async () => { + mockAdapter.consumeTokens.mockResolvedValue({ + allowed: true, + tokensRemaining: 9, + resetAt: new Date(Date.now() + 60000), + } satisfies ConsumeResult) + process.env.EXA_API_KEY_COUNT = undefined + process.env.EXA_API_KEY = 'singular-test-key' + + const result = await rateLimiter.acquireKey( + testProvider, + envKeyPrefix, + perRequestRateLimit, + 'workspace-1' + ) + + expect(result.success).toBe(true) + expect(result.key).toBe('singular-test-key') + expect(result.envVarName).toBe('EXA_API_KEY') + expect(result.keyIndex).toBe(0) + }) + it('should rate limit billing actor when wait exceeds the queue cap', async () => { // resetAt past the 5-minute cap forces the wait loop to bail immediately. const rateLimitedResult: ConsumeResult = { diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts index a40fff6176c..2638a385b21 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts @@ -278,7 +278,8 @@ export class HostedKeyRateLimiter { * back to `MAX_QUEUE_WAIT_MS`. On exhaustion the call returns today's 429 result. The * ticket is removed from the queue on exit regardless of success or failure. * - * @param envKeyPrefix - Env var prefix (e.g. 'EXA_API_KEY'). Keys resolved via `{prefix}_COUNT`. + * @param envKeyPrefix - Env var prefix (e.g. 'EXA_API_KEY'). Uses a numbered + * pool via `{prefix}_COUNT`, or the singular prefix when no count is set. * @param billingActorId - The billing actor (typically workspace ID) to rate limit against * @param signal - Optional execution `AbortSignal`; bounds the queue wait to the run's budget. */ diff --git a/apps/sim/tools/hosting.ts b/apps/sim/tools/hosting.ts new file mode 100644 index 00000000000..28261170fbc --- /dev/null +++ b/apps/sim/tools/hosting.ts @@ -0,0 +1,16 @@ +import type { ToolHostingCondition, ToolHostingPredicate } from '@/tools/types' + +/** + * Defines conditional hosted-key eligibility once for both runtime evaluation + * and the machine-readable VFS auth contract. + */ +export function hostedKeyEnabledWhen

(condition: ToolHostingCondition): ToolHostingPredicate

{ + const predicate = ((params: P) => { + const value = (params as unknown as Record)[condition.field] + if (condition.operator === 'equals') return value === condition.value + return condition.values.includes(value as string | number | boolean | null) + }) as ToolHostingPredicate

+ + predicate.condition = condition + return predicate +} diff --git a/apps/sim/tools/image/generate.ts b/apps/sim/tools/image/generate.ts index d100d84d66f..32a94ebb165 100644 --- a/apps/sim/tools/image/generate.ts +++ b/apps/sim/tools/image/generate.ts @@ -1,4 +1,5 @@ import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' +import { hostedKeyEnabledWhen } from '@/tools/hosting' import type { ImageGenerationParams, ImageGenerationResponse } from '@/tools/image/types' import type { ToolConfig } from '@/tools/types' @@ -115,7 +116,11 @@ export const imageGenerateTool: ToolConfig params.provider === 'falai', + enabled: hostedKeyEnabledWhen({ + field: 'provider', + operator: 'equals', + value: 'falai', + }), envKeyPrefix: 'FALAI_API_KEY', apiKeyParam: 'apiKey', byokProviderId: 'falai', diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 542e5e9a620..7c9da76dd2d 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -302,6 +302,23 @@ interface CustomPricing

> { /** Union of all pricing models */ export type ToolHostingPricing

> = PerRequestPricing | CustomPricing

+export type ToolHostingCondition = + | { + field: string + operator: 'equals' + value: string | number | boolean | null + } + | { + field: string + operator: 'one_of' + values: Array + } + +export type ToolHostingPredicate

= ((params: P) => boolean) & { + /** Serializable equivalent of this predicate for VFS consumers. */ + condition?: ToolHostingCondition +} + /** * Configuration for hosted API key support. * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own. @@ -323,16 +340,20 @@ export type ToolHostingPricing

> = PerRequestPricing * EXA_API_KEY_5=sk-... * ``` * + * For a single-key deployment, `{envKeyPrefix}` is also supported when no + * `{envKeyPrefix}_COUNT` is configured. + * * Adding more keys only requires updating the count and adding the new env var — * no code changes needed. */ export interface ToolHostingConfig

> { /** Optional predicate for tools where hosted keys only apply to some parameter combinations. */ - enabled?: (params: P) => boolean + enabled?: ToolHostingPredicate

/** * Env var name prefix for hosted keys. * At runtime, `{envKeyPrefix}_COUNT` is read to determine how many keys exist, - * then `{envKeyPrefix}_1` through `{envKeyPrefix}_N` are resolved. + * then `{envKeyPrefix}_1` through `{envKeyPrefix}_N` are resolved. If no count + * is configured, a singular `{envKeyPrefix}` is used when present. */ envKeyPrefix: string /** The parameter name that receives the API key */ From 225c6b89e2ad18b4d5a3460f984555b447965f67 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 16 Jul 2026 13:50:00 -0700 Subject: [PATCH 05/10] fix(mship): show icons on question exed out --- .../components/chat-content/chat-content.tsx | 3 ++ .../components/question/question.test.ts | 33 ++++++++++++++- .../components/question/question.tsx | 8 +++- .../components/special-tags/special-tags.tsx | 9 ++++- .../message-content/message-content.tsx | 3 ++ .../message-actions-visibility.test.ts | 40 +++++++++++++++++++ .../message-actions-visibility.ts | 22 ++++++++++ .../mothership-chat/mothership-chat.tsx | 23 +++++++++-- 8 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts 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 580483bc8b3..489eb3e1c62 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 @@ -347,6 +347,7 @@ interface ChatContentProps { /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] onOptionSelect?: (id: string) => void + onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void /** Reports whether this segment is actively painting text or its own pending-tag indicator. */ @@ -358,6 +359,7 @@ function ChatContentInner({ isStreaming = false, questionAnswers, onOptionSelect, + onQuestionDismiss, onWorkspaceResourceSelect, onRevealStateChange, onStreamActivityChange, @@ -570,6 +572,7 @@ function ChatContentInner({ segment={group.segment} questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} + onQuestionDismiss={onQuestionDismiss} /> ) })} 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 e3761f3d0e7..c22bc12ac5b 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 @@ -3,7 +3,7 @@ */ import { act, createElement } from 'react' import { createRoot } from 'react-dom/client' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { formatQuestionAnswerMessage, parseQuestionAnswerMessage, @@ -89,6 +89,37 @@ describe('parseQuestionAnswerMessage', () => { }) describe('QuestionDisplay', () => { + it('reports dismissal when the X hides the card', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onDismiss = vi.fn() + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[0]], + onSelect: () => undefined, + onDismiss, + }) + ) + }) + + const dismissButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Dismiss') + ) + expect(dismissButton).toBeDefined() + + act(() => dismissButton?.click()) + + expect(onDismiss).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain(QUESTIONS[0].prompt) + + act(() => root.unmount()) + container.remove() + }) + it('renders multi-select recap answers as separate, spaced rows', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') 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 9475f0fd4da..53811b94c04 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 @@ -86,6 +86,8 @@ interface QuestionDisplayProps { answers?: string[] /** Sends the combined answer as a user message; undefined renders the div inert. */ onSelect?: (message: string) => void + /** Reports that the active card was dismissed so its message actions can return. */ + onDismiss?: () => void } /** @@ -103,6 +105,7 @@ export function QuestionDisplay({ data, answers: transcriptAnswers, onSelect, + onDismiss, }: QuestionDisplayProps) { const freeTextInputRef = useRef(null) const freeTextCheckboxRef = useRef(null) @@ -292,7 +295,10 @@ export function QuestionDisplay({