@@ -218,6 +232,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
isStreaming={isStreaming}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
+ onQuestionDismiss={handleQuestionDismiss}
onPhaseChange={setPhase}
/>
{showActions && (
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/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
index df2631a16f7..93757767892 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts
@@ -142,79 +142,61 @@ describe('reconcileLiveAssistantTurn', () => {
})
describe('selectReconnectReplayState', () => {
- it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => {
- const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' }
+ it('continues from a nonzero cursor when live streaming state exists in memory', () => {
+ const currentBlock: ContentBlock = { type: 'text', content: 'Hello world' }
const result = selectReconnectReplayState({
afterCursor: '4',
- cachedLiveAssistant: {
- content: 'Hello world',
- contentBlocks: [cachedBlock],
- },
- currentContent: 'Hello',
- currentBlocks: [],
+ currentContent: 'Hello world',
+ currentBlocks: [currentBlock],
})
expect(result).toEqual({
afterCursor: '4',
- content: 'Hello world',
- contentBlocks: [cachedBlock],
preserveExistingState: true,
- source: 'cache',
+ source: 'live',
})
})
- it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => {
+ it('continues when only blocks carry live state (e.g. tool-only turn)', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
- cachedLiveAssistant: null,
currentContent: '',
- currentBlocks: [],
+ currentBlocks: [{ type: 'tool_call', toolCall: { id: 't1', name: 'grep' } } as ContentBlock],
})
expect(result).toEqual({
- afterCursor: '0',
- content: '',
- contentBlocks: [],
- preserveExistingState: false,
- source: 'reset',
+ afterCursor: '4',
+ preserveExistingState: true,
+ source: 'live',
})
})
- it('resets when cached live content diverges from the local prefix', () => {
+ it('replays the buffer from seq 0 when a nonzero cursor has no live in-memory state', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
- cachedLiveAssistant: {
- content: 'Goodbye world',
- contentBlocks: [{ type: 'text', content: 'Goodbye world' }],
- },
- currentContent: 'Hello',
- currentBlocks: [{ type: 'text', content: 'Hello' }],
+ currentContent: '',
+ currentBlocks: [],
})
expect(result).toEqual({
afterCursor: '0',
- content: '',
- contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
})
- it('resets current state for cursor zero replay', () => {
+ it('resets for cursor zero replay even when local state exists', () => {
const currentBlock: ContentBlock = { type: 'text', content: 'Hello' }
const result = selectReconnectReplayState({
afterCursor: '0',
- cachedLiveAssistant: null,
currentContent: 'Hello',
currentBlocks: [currentBlock],
})
expect(result).toEqual({
afterCursor: '0',
- content: '',
- contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 0dedc8c8bd9..10e961b6386 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -784,6 +784,22 @@ function isZeroStreamCursor(cursor: string): boolean {
return Number.isFinite(sequence) && sequence <= 0
}
+/**
+ * The resume endpoint 404s when no run exists for the stream — there is
+ * nothing left to resume, so reconnect falls back to the persisted DB
+ * transcript instead of retrying or surfacing an error.
+ */
+class StreamGoneError extends Error {
+ constructor(streamId: string) {
+ super(`Stream ${streamId} no longer exists`)
+ this.name = 'StreamGoneError'
+ }
+}
+
+function isStreamGoneError(error: unknown): error is StreamGoneError {
+ return error instanceof Error && error.name === 'StreamGoneError'
+}
+
function isPersistedAssistantMessage(message: PersistedMessage, liveAssistantId: string): boolean {
return (
message.role === 'assistant' &&
@@ -874,55 +890,32 @@ export function reconcileLiveAssistantTurn(params: {
export interface ReconnectReplaySelection {
afterCursor: string
- content: string
- contentBlocks: ContentBlock[]
preserveExistingState: boolean
- source: 'cache' | 'reset'
+ source: 'live' | 'reset'
}
+/**
+ * Decides how a reconnect replay starts. The only state a resumed stream may
+ * continue from is the live in-memory pair (streaming refs + lastCursorRef)
+ * maintained together by this mount's stream loop — those are coherent by
+ * construction. Anything else (fresh mount, cleared refs, cache-derived
+ * transcripts) replays the Redis buffer from seq 0 into a fresh model: the
+ * buffer is the source of truth for an in-flight turn and replay is
+ * idempotent, so a full rebuild is always safe. Seeding the model from a
+ * cached transcript paired stale content with a newer cursor, which dropped
+ * replayed events and rendered empty or suffix-only messages.
+ */
export function selectReconnectReplayState(params: {
afterCursor: string
- cachedLiveAssistant?: Pick
| null
currentContent: string
currentBlocks: ContentBlock[]
}): ReconnectReplaySelection {
- const { afterCursor, cachedLiveAssistant, currentContent, currentBlocks } = params
- if (isZeroStreamCursor(afterCursor)) {
- return {
- afterCursor,
- content: '',
- contentBlocks: [],
- preserveExistingState: false,
- source: 'reset',
- }
- }
-
- const cachedContent = cachedLiveAssistant?.content ?? ''
- const cachedBlocks = cachedLiveAssistant?.contentBlocks ?? []
- const cachedHasLiveState = cachedContent.length > 0 || cachedBlocks.length > 0
- const cachedIsAhead =
- cachedHasLiveState &&
- cachedContent.length >= currentContent.length &&
- cachedContent.startsWith(currentContent) &&
- cachedBlocks.length >= currentBlocks.length
-
- if (cachedIsAhead) {
- return {
- afterCursor,
- content: cachedContent,
- contentBlocks: [...cachedBlocks],
- preserveExistingState: true,
- source: 'cache',
- }
- }
-
- return {
- afterCursor: '0',
- content: '',
- contentBlocks: [],
- preserveExistingState: false,
- source: 'reset',
+ const { afterCursor, currentContent, currentBlocks } = params
+ const hasLiveState = currentContent.length > 0 || currentBlocks.length > 0
+ if (!isZeroStreamCursor(afterCursor) && hasLiveState) {
+ return { afterCursor, preserveExistingState: true, source: 'live' }
}
+ return { afterCursor: '0', preserveExistingState: false, source: 'reset' }
}
export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set {
@@ -1215,17 +1208,6 @@ export function useChat(
}
) => Promise<{ sawStreamError: boolean; sawComplete: boolean }>
>(async () => ({ sawStreamError: false, sawComplete: false }))
- const attachToExistingStreamRef = useRef<
- (opts: {
- streamId: string
- assistantId: string
- expectedGen: number
- initialBatch?: StreamBatchResponse | null
- afterCursor?: string
- targetChatId?: string
- shouldContinue?: () => boolean
- }) => Promise<{ error: boolean; aborted: boolean }>
- >(async () => ({ error: false, aborted: true }))
const retryReconnectRef = useRef<
(opts: {
streamId: string
@@ -1311,25 +1293,9 @@ export function useChat(
}, [])
const applyReconnectReplaySelection = useCallback(
- (
- streamId: string,
- assistantId: string,
- afterCursor: string,
- options?: { targetChatId?: string; chatHistory?: MothershipChatHistory }
- ): ReconnectReplaySelection => {
- const cachedHistory =
- options?.chatHistory ??
- (options?.targetChatId
- ? queryClient.getQueryData(
- mothershipChatKeys.detail(options.targetChatId)
- )
- : undefined)
- const cachedLiveAssistant = cachedHistory?.messages.find(
- (message) => message.id === assistantId
- )
+ (streamId: string, afterCursor: string): ReconnectReplaySelection => {
const selection = selectReconnectReplayState({
afterCursor,
- cachedLiveAssistant: cachedLiveAssistant ? toDisplayMessage(cachedLiveAssistant) : null,
currentContent: streamingContentRef.current,
currentBlocks: streamingBlocksRef.current,
})
@@ -1338,23 +1304,18 @@ export function useChat(
// these refs — keep the previous snapshot visible (and stop-persistable)
// until the replay's terminal flush overwrites it, instead of collapsing
// the rendered message to empty.
- if (selection.source === 'cache') {
- streamingContentRef.current = selection.content
- streamingBlocksRef.current = selection.contentBlocks
- }
lastCursorRef.current = selection.afterCursor
if (selection.afterCursor === '0' && afterCursor !== '0') {
logger.info('Resetting stream replay cursor after reconnect state mismatch', {
streamId,
- targetChatId: options?.targetChatId ?? cachedHistory?.id,
previousCursor: afterCursor,
})
}
return selection
},
- [queryClient]
+ []
)
const clearActiveTurn = useCallback(() => {
@@ -1756,18 +1717,11 @@ export function useChat(
const activeStreamId = chatHistory.activeStreamId
appliedChatHistoryKeyRef.current = hydrationKey
const mappedMessages = chatHistory.messages.map(toDisplayMessage)
- const snapshotEvents = Array.isArray(chatHistory.streamSnapshot?.events)
- ? chatHistory.streamSnapshot.events
- : []
- const snapshotHasCompleteEvent = snapshotEvents.some(
- (entry) => entry?.event?.type === MothershipStreamV1EventType.complete
- )
const shouldReconnectActiveStream =
Boolean(activeStreamId) &&
!sendingRef.current &&
activeStreamId !== locallyTerminalStreamIdRef.current &&
- !isTerminalStreamStatus(chatHistory.streamSnapshot?.status) &&
- !snapshotHasCompleteEvent
+ !isTerminalStreamStatus(chatHistory.streamSnapshot?.status)
if (!activeStreamId && locallyTerminalStreamIdRef.current) {
locallyTerminalStreamIdRef.current = undefined
@@ -1825,9 +1779,6 @@ export function useChat(
if (shouldReconnectActiveStream && activeStreamId) {
const gen = ++streamGenRef.current
const abortController = new AbortController()
- const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId
- const reconnectAfterCursor =
- previousStreamId === activeStreamId ? lastCursorRef.current || '0' : '0'
cancelActiveStreamRecovery()
const replacedController = abortControllerRef.current
if (replacedController && !replacedController.signal.aborted) {
@@ -1838,68 +1789,24 @@ export function useChat(
streamIdRef.current = activeStreamId
setTransportReconnecting()
+ // Load-time reconnects always rebuild the live turn from the Redis
+ // replay buffer (seq 0): the buffer is the source of truth for an
+ // in-flight turn, and any local state here is detached from the stream
+ // loop that produced it. The DB transcript only supplies prior turns.
+ // If the buffer is empty on a terminal run, the resume flow finalizes
+ // and refetches the persisted transcript from the DB instead.
const assistantId = getLiveAssistantMessageId(activeStreamId)
- let snapshotReplayAfterCursor: string
- if (snapshotEvents.length > 0) {
- streamingContentRef.current = ''
- streamingBlocksRef.current = []
- lastCursorRef.current = '0'
- snapshotReplayAfterCursor = '0'
- } else {
- const replaySelection = applyReconnectReplaySelection(
- activeStreamId,
- assistantId,
- reconnectAfterCursor,
- { targetChatId: chatHistory.id, chatHistory }
- )
- snapshotReplayAfterCursor = replaySelection.afterCursor
- }
+ streamingContentRef.current = ''
+ streamingBlocksRef.current = []
+ lastCursorRef.current = '0'
const reconnect = async () => {
- const initialSnapshot = chatHistory.streamSnapshot
- const snapshotEvents = Array.isArray(initialSnapshot?.events)
- ? (initialSnapshot.events as StreamBatchEvent[])
- : []
-
- let reconnectResult: Awaited> | null =
- null
- const replaySnapshotEvents = snapshotEvents.filter(
- (entry) =>
- !isAlreadyProcessedStreamCursor(String(entry.eventId), snapshotReplayAfterCursor)
- )
- if (replaySnapshotEvents.length > 0) {
- try {
- reconnectResult = await attachToExistingStreamRef.current({
- streamId: activeStreamId,
- assistantId,
- expectedGen: gen,
- initialBatch: {
- success: true,
- events: replaySnapshotEvents,
- previewSessions: snapshotPreviewSessions,
- status: initialSnapshot?.status ?? 'unknown',
- },
- afterCursor: snapshotReplayAfterCursor,
- targetChatId: chatHistory.id,
- })
- } catch (error) {
- logger.warn('Snapshot stream reconnect failed; falling back to retry', {
- chatId: chatHistory.id,
- streamId: activeStreamId,
- error: toError(error).message,
- })
- }
- }
-
- const succeeded =
- reconnectResult !== null
- ? !reconnectResult.error || reconnectResult.aborted
- : await retryReconnectRef.current({
- streamId: activeStreamId,
- assistantId,
- gen,
- targetChatId: chatHistory.id,
- })
+ const succeeded = await retryReconnectRef.current({
+ streamId: activeStreamId,
+ assistantId,
+ gen,
+ targetChatId: chatHistory.id,
+ })
if (succeeded && streamGenRef.current === gen && sendingRef.current) {
finalizeRef.current({ targetChatId: chatHistory.id })
return
@@ -1927,10 +1834,8 @@ export function useChat(
cancelActiveStreamReader,
cancelActiveStreamRecovery,
flushPendingResources,
- queryClient,
recoverPendingClientWorkflowTools,
seedPreviewSessions,
- applyReconnectReplaySelection,
setTransportIdle,
setTransportReconnecting,
])
@@ -2134,6 +2039,9 @@ export function useChat(
: {}),
}
)
+ if (response.status === 404) {
+ throw new StreamGoneError(streamId)
+ }
if (!response.ok) {
throw new Error(`Stream resume batch failed: ${response.status}`)
}
@@ -2232,14 +2140,14 @@ export function useChat(
return { error: false, aborted: true }
}
+ // `afterCursor` must be the cursor the current streaming refs correspond
+ // to (or '0' with a fresh rebuild) — the seed replay re-baselines the
+ // rebuilt model's seq high-water mark to it, so a cursor ahead of the
+ // refs silently drops the seed events as replays.
const initialReplaySelection: Pick<
ReconnectReplaySelection,
'afterCursor' | 'preserveExistingState'
- > = opts.initialBatch
- ? { afterCursor, preserveExistingState: true }
- : applyReconnectReplaySelection(streamId, assistantId, afterCursor, {
- ...(targetChatId ? { targetChatId } : {}),
- })
+ > = applyReconnectReplaySelection(streamId, afterCursor)
let latestCursor = initialReplaySelection.afterCursor
let preserveNextReplayState = initialReplaySelection.preserveExistingState
let seedEvents = opts.initialBatch?.events ?? []
@@ -2303,6 +2211,9 @@ export function useChat(
: {}),
}
)
+ if (sseRes.status === 404) {
+ throw new StreamGoneError(streamId)
+ }
if (!sseRes.ok || !sseRes.body) {
throw new Error(RECONNECT_TAIL_ERROR)
}
@@ -2356,9 +2267,9 @@ export function useChat(
streamStatus = batch.status
suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents)
- if (batch.events.length > 0) {
- latestCursor = String(batch.events[batch.events.length - 1].eventId)
- }
+ // `latestCursor` stays at the pre-batch position so the seed replay
+ // at the top of the loop folds the batch events into the model; the
+ // replay advances the cursor after applying them.
if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) {
if (activeAbort.signal.aborted || streamGenRef.current !== expectedGen) {
@@ -2392,7 +2303,6 @@ export function useChat(
setTransportStreaming,
]
)
- attachToExistingStreamRef.current = attachToExistingStream
const resumeOrFinalize = useCallback(
async (opts: {
@@ -2408,9 +2318,7 @@ export function useChat(
if (streamGenRef.current !== gen || signal?.aborted || shouldContinue?.() === false) return
- const replaySelection = applyReconnectReplaySelection(streamId, assistantId, afterCursor, {
- ...(targetChatId ? { targetChatId } : {}),
- })
+ const replaySelection = applyReconnectReplaySelection(streamId, afterCursor)
const batch = await fetchStreamBatch(streamId, replaySelection.afterCursor, signal)
if (streamGenRef.current !== gen || shouldContinue?.() === false) return
seedStreamBatchPreviewSessions(batch)
@@ -2439,6 +2347,12 @@ export function useChat(
return
}
+ // Pass the cursor the streaming refs correspond to — NOT the batch's
+ // last event id. The seed replay re-baselines the rebuilt model to this
+ // cursor before folding the batch in; a cursor already advanced past
+ // the batch made the replay drop every event as a duplicate, which
+ // rendered an empty message (and suffix-only text once the tail
+ // appended to it).
const reconnectResult = await attachToExistingStream({
streamId,
assistantId,
@@ -2446,10 +2360,7 @@ export function useChat(
initialBatch: batch,
...(targetChatId ? { targetChatId } : {}),
...(shouldContinue ? { shouldContinue } : {}),
- afterCursor:
- batch.events.length > 0
- ? String(batch.events[batch.events.length - 1].eventId)
- : replaySelection.afterCursor,
+ afterCursor: replaySelection.afterCursor,
})
if (
@@ -2568,6 +2479,18 @@ export function useChat(
}
return true
}
+ if (isStreamGoneError(err)) {
+ // Nothing left to resume (no run for the stream) — the persisted
+ // DB transcript is authoritative now. Finalize so the detail
+ // query refetches it instead of surfacing a reconnect error.
+ logger.warn('Stream no longer exists; falling back to persisted transcript', {
+ streamId,
+ })
+ if (streamGenRef.current === gen) {
+ finalizeRef.current({ ...(targetChatId ? { targetChatId } : {}) })
+ }
+ return true
+ }
if (isStreamSchemaValidationError(err)) {
logger.error('Reconnect halted by client-side stream schema enforcement', {
streamId,
diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts
index 2f8caa114f7..44e36084b0d 100644
--- a/apps/sim/lib/copilot/chat/workspace-context.test.ts
+++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts
@@ -74,6 +74,21 @@ describe('buildWorkspaceMd - workflow VFS state paths', () => {
expect(md).toContain('VFS dir: `workflows/Root%20Flow`')
expect(md).toContain('VFS state path: `workflows/Root%20Flow/state.json`')
})
+
+ it('never exposes workflow descriptions in markdown or the typed snapshot', () => {
+ const workflowWithPrivateDescription = {
+ id: 'wf-1',
+ name: 'Private Flow',
+ description: 'PRIVATE WORKFLOW DESCRIPTION',
+ isDeployed: false,
+ folderPath: null,
+ }
+ const data = baseData({ workflows: [workflowWithPrivateDescription] })
+
+ expect(buildWorkspaceMd(data)).not.toContain('PRIVATE WORKFLOW DESCRIPTION')
+ expect(JSON.stringify(buildVfsSnapshot(data))).not.toContain('PRIVATE WORKFLOW DESCRIPTION')
+ expect(buildVfsSnapshot(data).workflows?.[0]).not.toHaveProperty('description')
+ })
})
describe('buildWorkspaceMd - connected integrations / credentials', () => {
@@ -115,6 +130,16 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => {
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..11ee6ac3702 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'
@@ -52,7 +55,6 @@ export interface WorkspaceMdData {
workflows: Array<{
id: string
name: string
- description?: string | null
isDeployed: boolean
lastRunAt?: Date | null
folderPath?: string | null
@@ -155,7 +157,6 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string {
const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath })
parts.push(`${indent} VFS dir: \`${workflowDir}\``)
parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``)
- if (wf.description) parts.push(`${indent} ${wf.description}`)
// `deployed` is a structural flag (kept); `lastRunAt` is intentionally
// omitted — it changes on every run and would bust the cached prompt
// prefix that carries this inventory. Current run data lives in
@@ -350,6 +351,7 @@ async function buildWorkspaceMdData(
tables,
files,
credentials,
+ envCredentials,
customTools,
mcpServerRows,
skillRows,
@@ -362,7 +364,6 @@ async function buildWorkspaceMdData(
.select({
id: workflow.id,
name: workflow.name,
- description: workflow.description,
isDeployed: workflow.isDeployed,
lastRunAt: workflow.lastRunAt,
folderId: workflow.folderId,
@@ -406,6 +407,8 @@ async function buildWorkspaceMdData(
getAccessibleOAuthCredentials(workspaceId, userId),
+ getAccessibleEnvCredentials(workspaceId, userId),
+
listCustomTools({ userId, workspaceId }),
db
@@ -511,7 +514,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,
@@ -577,7 +586,6 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
id: wf.id,
name: wf.name,
path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }),
- ...(wf.description ? { description: wf.description } : {}),
...(wf.isDeployed ? { isDeployed: true } : {}),
...(wf.folderPath ? { folderPath: wf.folderPath } : {}),
}))
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index d2bdf083b2f..db0ca729e07 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -439,7 +439,6 @@ export const CreateWorkflow: ToolCatalogEntry = {
parameters: {
type: 'object',
properties: {
- description: { type: 'string', description: 'Optional workflow description.' },
folderId: { type: 'string', description: 'Optional folder ID.' },
name: { type: 'string', description: 'Workflow name.' },
workspaceId: { type: 'string', description: 'Optional workspace ID.' },
@@ -844,7 +843,7 @@ export const DeployCustomBlock: ToolCatalogEntry = {
iconUrl: {
type: 'string',
description:
- 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon',
+ 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon',
},
inputs: {
type: 'array',
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index ba7e89ab8fa..34d854aa5f1 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -221,10 +221,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
parameters: {
type: 'object',
properties: {
- description: {
- type: 'string',
- description: 'Optional workflow description.',
- },
folderId: {
type: 'string',
description: 'Optional folder ID.',
@@ -648,7 +644,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
iconUrl: {
type: 'string',
description:
- 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon',
+ 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon',
},
inputs: {
type: 'array',
diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
index 9a4df7519b1..57cb552726e 100644
--- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
+++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts
@@ -113,7 +113,6 @@ export interface VfsSnapshotV1Table {
* via the `definition` "VfsSnapshotV1Workflow".
*/
export interface VfsSnapshotV1Workflow {
- description?: string
folderPath?: string
id: string
isDeployed?: boolean
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..7a849821895 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',
@@ -144,12 +149,35 @@ describe('resolveToolDisplay', () => {
resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
path: 'components/blocks/unknown_block.json',
})?.text
- ).toBe('Read unknown_block')
+ ).toBe('Read Unknown block')
+ })
+
+ it('humanizes internal VFS resource identifiers', () => {
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, {
+ path: 'environment/oauth-integrations.json',
+ })?.text
+ ).toBe('Reading OAuth integrations')
+
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'environment/oauth-integrations.json',
+ })?.text
+ ).toBe('Read OAuth integrations')
+
+ expect(
+ resolveToolDisplay(ReadTool.id, ClientToolCallState.success, {
+ path: 'environment/api-key-integrations.json',
+ })?.text
+ ).toBe('Read API key integrations')
})
it('falls back to a humanized tool label for generic tools', () => {
expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe(
- 'Executed Deploy Api'
+ 'Executed Deploy API'
+ )
+ expect(resolveToolDisplay('oauth-integrations', ClientToolCallState.success)?.text).toBe(
+ 'Executed OAuth Integrations'
)
})
diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts
index f143602dcd2..92bbeb6c572 100644
--- a/apps/sim/lib/copilot/tools/client/store-utils.ts
+++ b/apps/sim/lib/copilot/tools/client/store-utils.ts
@@ -6,6 +6,7 @@ import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
+import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display'
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
/** Respond tools are internal handoff tools shown with a friendly generic label. */
@@ -68,7 +69,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}`
@@ -98,7 +99,7 @@ function describeReadTarget(path: string | undefined): string | undefined {
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType) {
- return stripExtension(segments[segments.length - 1])
+ return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence')
}
if (resourceType === 'file') {
@@ -159,9 +160,9 @@ function humanizedFallback(
toolName: string,
state: ClientToolCallState
): ClientToolDisplay | undefined {
- const titleCaseName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+ const titleCaseName = humanizeToolName(toolName)
if (state === ClientToolCallState.error) {
- const lowerCaseName = toolName.replace(/_/g, ' ').toLowerCase()
+ const lowerCaseName = humanizeDisplayIdentifier(toolName, 'sentence')
return { text: `Attempted to ${lowerCaseName}`, icon: Loader }
}
const stateVerb =
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/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/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
index 58cb5c880ea..02d4a83c70b 100644
--- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
+++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts
@@ -13,7 +13,6 @@ import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync'
import {
applyDescriptionOverrides,
generateToolInputSchema,
- getMeaningfulWorkflowDescription,
sanitizeToolName,
} from '@/lib/mcp/workflow-tool-schema'
import {
@@ -612,9 +611,7 @@ export async function executeDeployMcp(
params.toolName || workflowRecord.name || `workflow_${workflowId}`
)
const toolDescription =
- params.toolDescription?.trim() ||
- getMeaningfulWorkflowDescription(workflowRecord.description, workflowRecord.name) ||
- `Execute ${workflowRecord.name} workflow`
+ params.toolDescription?.trim() || `Execute ${workflowRecord.name} workflow`
/**
* Parameter names/types come from the workflow's deployed input trigger; this tool only sets
* per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the
diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts
index 98612386531..6671ddc33ec 100644
--- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts
@@ -59,6 +59,17 @@ vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() })
import type { ExecutionContext } from '@/lib/copilot/request/types'
import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file'
+import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
+import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
+import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
+import { deduplicateWorkflowName } from '@/lib/workflows/utils'
+import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
+
+const fetchWorkspaceFileBufferMock = vi.mocked(fetchWorkspaceFileBuffer)
+const parseWorkflowJsonMock = vi.mocked(parseWorkflowJson)
+const saveWorkflowToNormalizedTablesMock = vi.mocked(saveWorkflowToNormalizedTables)
+const deduplicateWorkflowNameMock = vi.mocked(deduplicateWorkflowName)
+const extractWorkflowMetadataMock = vi.mocked(extractWorkflowMetadata)
const context = {
chatId: 'chat-1',
@@ -123,6 +134,45 @@ describe('executeMaterializeFile - unsupported operation', () => {
})
})
+describe('executeMaterializeFile - workflow import', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mockFindUpload.mockResolvedValue({
+ ...mothershipRow,
+ originalName: 'workflow.json',
+ displayName: 'workflow.json',
+ contentType: 'application/json',
+ })
+ fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('{"metadata":{}}'))
+ parseWorkflowJsonMock.mockReturnValue({
+ data: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: [] },
+ errors: [],
+ })
+ extractWorkflowMetadataMock.mockReturnValue({
+ name: 'Imported Workflow',
+ description: 'PRIVATE WORKFLOW DESCRIPTION',
+ })
+ deduplicateWorkflowNameMock.mockResolvedValue('Imported Workflow')
+ saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true })
+ })
+
+ it('does not persist the uploaded workflow description', async () => {
+ const result = await executeMaterializeFile(
+ { fileNames: ['workflow.json'], operation: 'import' },
+ context
+ )
+
+ expect(result.success).toBe(true)
+ const insertedWorkflow = dbChainMockFns.values.mock.calls[0]?.[0] as Record
+ expect(insertedWorkflow).toMatchObject({ name: 'Imported Workflow' })
+ expect(insertedWorkflow).not.toHaveProperty('description')
+ expect(JSON.stringify(dbChainMockFns.values.mock.calls)).not.toContain(
+ 'PRIVATE WORKFLOW DESCRIPTION'
+ )
+ })
+})
+
describe('executeMaterializeFile - save storage transition', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts
index 59d39906878..407e41b58ab 100644
--- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts
+++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts
@@ -177,7 +177,7 @@ async function executeImport(
}
}
- const { name: rawName, description: workflowDescription } = extractWorkflowMetadata(parsed)
+ const { name: rawName } = extractWorkflowMetadata(parsed)
const workflowId = generateId()
const now = new Date()
@@ -189,7 +189,6 @@ async function executeImport(
workspaceId,
folderId: null,
name: dedupedName,
- description: workflowDescription,
lastSynced: now,
createdAt: now,
updatedAt: now,
diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts
index 19eae663ba8..01544f6587c 100644
--- a/apps/sim/lib/copilot/tools/handlers/param-types.ts
+++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts
@@ -33,7 +33,6 @@ export interface CreateWorkflowParams {
name?: string
workspaceId?: string
folderId?: string
- description?: string
}
export interface CreateFolderParams {
@@ -247,12 +246,6 @@ export interface RenameWorkflowParams {
name: string
}
-export interface UpdateWorkflowParams {
- workflowId: string
- name?: string
- description?: string
-}
-
export interface DeleteWorkflowParams {
workflowIds: string[]
}
diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts
index b2cd2356ead..1720cd1c668 100644
--- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts
+++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts
@@ -293,6 +293,33 @@ describe('executeCreateWorkflow billing attribution', () => {
reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true })
})
+ it('ignores legacy description input instead of persisting it', async () => {
+ performCreateWorkflowMock.mockResolvedValue({
+ success: true,
+ workflow: {
+ id: 'created-workflow',
+ name: 'Created Workflow',
+ workspaceId: 'workspace-1',
+ folderId: null,
+ },
+ })
+ const legacyParams = {
+ name: 'Created Workflow',
+ workspaceId: 'workspace-1',
+ description: 'PRIVATE WORKFLOW DESCRIPTION',
+ } as Parameters[0]
+
+ const result = await executeCreateWorkflow(legacyParams, executionContext)
+
+ expect(result.success).toBe(true)
+ expect(performCreateWorkflowMock).toHaveBeenCalledWith({
+ userId: 'user-1',
+ workspaceId: 'workspace-1',
+ name: 'Created Workflow',
+ folderId: null,
+ })
+ })
+
it('keeps same-workspace creation and subsequent execution on the immutable payer', async () => {
const context: ExecutionContext = { ...executionContext, workflowId: '' }
performCreateWorkflowMock.mockResolvedValue({
diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts
index a690b2d2f0c..65d6a84f60e 100644
--- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts
+++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts
@@ -365,7 +365,6 @@ import type {
RunWorkflowUntilBlockParams,
SetBlockEnabledParams,
SetGlobalWorkflowVariablesParams,
- UpdateWorkflowParams,
VariableOperation,
} from '../param-types'
@@ -392,11 +391,6 @@ export async function executeCreateWorkflow(
if (name.length > 200) {
return { success: false, error: 'Workflow name must be 200 characters or less' }
}
- const description = typeof params?.description === 'string' ? params.description : null
- if (description && description.length > 2000) {
- return { success: false, error: 'Description must be 2000 characters or less' }
- }
-
const workspaceId =
params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId))
const folderId = params?.folderId || null
@@ -409,7 +403,6 @@ export async function executeCreateWorkflow(
userId: context.userId,
workspaceId,
name,
- description,
folderId,
})
if (!result.success || !result.workflow) {
@@ -957,64 +950,6 @@ export async function executeRunFromBlock(
}
}
-async function executeUpdateWorkflow(
- params: UpdateWorkflowParams,
- context: ExecutionContext
-): Promise {
- try {
- const workflowId = params.workflowId
- if (!workflowId) {
- return { success: false, error: 'workflowId is required' }
- }
-
- const updates: { name?: string; description?: string } = {}
-
- if (typeof params.name === 'string') {
- const name = params.name.trim()
- if (!name) return { success: false, error: 'name cannot be empty' }
- if (name.length > 200)
- return { success: false, error: 'Workflow name must be 200 characters or less' }
- updates.name = name
- }
-
- if (typeof params.description === 'string') {
- if (params.description.length > 2000) {
- return { success: false, error: 'Description must be 2000 characters or less' }
- }
- updates.description = params.description
- }
-
- if (Object.keys(updates).length === 0) {
- return { success: false, error: 'At least one of name or description is required' }
- }
-
- const current = await ensureWorkflowAccess(workflowId, context.userId, 'write')
- if (!current.workspaceId) {
- return { success: false, error: 'Workflow workspace is required' }
- }
- await assertWorkflowMutable(workflowId)
- assertWorkflowMutationNotAborted(context)
- const result = await performUpdateWorkflow({
- workflowId,
- userId: context.userId,
- workspaceId: current.workspaceId,
- currentName: current.workflow.name,
- currentFolderId: current.workflow.folderId,
- ...updates,
- })
- if (!result.success) {
- return { success: false, error: result.error || 'Failed to update workflow' }
- }
-
- return {
- success: true,
- output: { workflowId, ...updates },
- }
- } catch (error) {
- return { success: false, error: toError(error).message }
- }
-}
-
export async function executeSetBlockEnabled(
params: SetBlockEnabledParams,
context: ExecutionContext
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..da571a7cd01 100644
--- a/apps/sim/lib/copilot/tools/tool-display.test.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.test.ts
@@ -2,18 +2,66 @@
* @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,
+ getToolStatusDisplayTitle,
humanizeToolName,
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
+}
+
+function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[] {
+ if (!entry.parameters || typeof entry.parameters !== 'object') return []
+ const properties = (entry.parameters as { properties?: unknown }).properties
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return []
+ const schema = (properties as 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')
})
+ it('title-cases kebab-case names', () => {
+ expect(humanizeToolName('read-oauth-integrations')).toBe('Read OAuth Integrations')
+ })
+
it('keeps canonical acronym casing', () => {
expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server')
expect(humanizeToolName('deploy_api')).toBe('Deploy API')
@@ -35,6 +83,76 @@ 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([])
+ })
+
+ 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', () => {
+ 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 +167,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', () => {
@@ -56,6 +178,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', () => {
@@ -93,6 +223,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,14 +333,142 @@ 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')
+ expect(getToolDisplayTitle('mcp-363de040-read-oauth-integrations')).toBe(
+ 'Read OAuth Integrations'
+ )
})
})
describe('getToolDisplayTitle for context management', () => {
it('describes compaction in user-facing language', () => {
expect(getToolDisplayTitle('context_compaction')).toBe('Summarizing context')
+ expect(getToolStatusDisplayTitle('Summarizing context', 'success')).toBe('Summarized context')
})
})
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 9ed7e1df6e9..8cc89868cc6 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,
}
@@ -266,17 +503,35 @@ const ACRONYM_CASING: Record = {
}
/**
- * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage
- * Folder"), matching the legacy client humanizer so labels never render blank.
+ * Humanize an internal identifier without leaking snake_case or kebab-case into
+ * the UI. Sentence case is useful for resource names appended to a verb, while
+ * title case is used for standalone tool-name fallbacks.
*/
-export function humanizeToolName(name: string): string {
- const words = stripVersionSuffix(name).split('_').filter(Boolean)
+export function humanizeDisplayIdentifier(
+ name: string,
+ casing: 'sentence' | 'title' = 'title'
+): string {
+ const words = stripVersionSuffix(name).split(/[-_]+/).filter(Boolean)
if (words.length === 0) return name
return words
- .map((word) => ACRONYM_CASING[word] ?? word.charAt(0).toUpperCase() + word.slice(1))
+ .map((word, index) => {
+ const normalized = word.toLowerCase()
+ const acronym = ACRONYM_CASING[normalized]
+ if (acronym) return acronym
+ if (casing === 'sentence' && index > 0) return normalized
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1)
+ })
.join(' ')
}
+/**
+ * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage
+ * Folder"), matching the legacy client humanizer so labels never render blank.
+ */
+export function humanizeToolName(name: string): string {
+ return humanizeDisplayIdentifier(name)
+}
+
/**
* Resolve a tool-call display title from its name and arguments. Argument-aware
* cases come first, then the static map, then a humanized fallback. This never
@@ -289,6 +544,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 +817,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 +857,21 @@ 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',
+ Summarizing: 'Summarized',
+ Syncing: 'Synced',
Toggling: 'Toggled',
+ Trimming: 'Trimmed',
+ Undeploying: 'Undeployed',
Updating: 'Updated',
Validating: 'Validated',
+ Viewing: 'Viewed',
Writing: 'Wrote',
}
@@ -556,3 +891,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.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts
index 8b6de1abddc..728db16cd6d 100644
--- a/apps/sim/lib/copilot/vfs/serializers.test.ts
+++ b/apps/sim/lib/copilot/vfs/serializers.test.ts
@@ -2,7 +2,46 @@
* @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,
+ serializeWorkflowMeta,
+} 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', () => {
@@ -48,6 +87,135 @@ describe('VFS metadata serializers', () => {
expect(table.rowCount).toBe(137)
expect(knowledgeBase.documentCount).toBe(19)
})
+
+ it('never includes a workflow description in workflow metadata', () => {
+ const workflowWithPrivateDescription = {
+ id: 'workflow-1',
+ name: 'Private Flow',
+ description: 'PRIVATE WORKFLOW DESCRIPTION',
+ folderId: null,
+ isDeployed: false,
+ deployedAt: null,
+ runCount: 0,
+ lastRunAt: null,
+ createdAt: new Date('2026-07-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-07-02T00:00:00.000Z'),
+ }
+
+ const metadata = JSON.parse(serializeWorkflowMeta(workflowWithPrivateDescription))
+
+ expect(metadata).not.toHaveProperty('description')
+ expect(JSON.stringify(metadata)).not.toContain('PRIVATE WORKFLOW DESCRIPTION')
+ })
+})
+
+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', () => {
diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts
index ae7a971a794..c5cd191208f 100644
--- a/apps/sim/lib/copilot/vfs/serializers.ts
+++ b/apps/sim/lib/copilot/vfs/serializers.ts
@@ -5,7 +5,54 @@ 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 =
+ | {
+ type: 'oauth'
+ required: boolean
+ provider: string
+ }
+ | {
+ type: 'api_key'
+ param: string
+ mode: 'hosted_or_byok' | 'conditional_hosted_or_byok' | 'byok_required'
+ provider?: string
+ condition?: ToolHostingCondition
+ }
+
+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,
+ condition: hosted ? tool.hosting.enabled?.condition : undefined,
+ }
+}
/**
* Serialize workflow metadata for VFS meta.json.
@@ -21,7 +68,6 @@ export function serializeWorkflowMeta(
wf: {
id: string
name: string
- description?: string | null
folderId?: string | null
isDeployed: boolean
deployedAt?: Date | null
@@ -39,7 +85,6 @@ export function serializeWorkflowMeta(
{
id: wf.id,
name: wf.name,
- description: wf.description || undefined,
folderId: wf.folderId || undefined,
locked,
lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined,
@@ -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(
@@ -557,6 +621,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.
@@ -758,8 +871,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 +887,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..d2cdf671eb1 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,
@@ -92,7 +93,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 +134,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 +229,28 @@ 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()
// 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))
@@ -261,19 +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)
- if (existing) {
- if (!existing.params.includes(tool.hosting.apiKeyParam)) {
- existing.params.push(tool.hosting.apiKeyParam)
- }
- existing.operations.push(operation)
- } else {
- apiKeyServices.set(service, {
- params: [tool.hosting.apiKeyParam],
- operations: [operation],
- })
- }
}
}
@@ -283,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(
@@ -1526,7 +1520,6 @@ export class WorkspaceVFS {
return workflowRows.map((wf) => ({
id: wf.id,
name: wf.name,
- description: wf.description,
isDeployed: wf.isDeployed,
lastRunAt: wf.lastRunAt,
folderPath: wf.folderId ? (folderPaths.get(wf.folderId) ?? null) : null,
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 d199ed9e2c2..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
@@ -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}`)
@@ -272,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/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/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
}
diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts
index 0d3c3232b28..b69517a7bb0 100644
--- a/apps/sim/providers/trace-enrichment.ts
+++ b/apps/sim/providers/trace-enrichment.ts
@@ -13,6 +13,12 @@ interface ChatCompletionLike {
message?: {
content?: string | null
tool_calls?: Array | null
+ reasoning_content?: string | null
+ reasoning?: string | null
+ reasoning_details?: Array<{
+ text?: string | null
+ summary?: string | null
+ } | null> | null
} | null
finish_reason?: string | null
} | null>
@@ -126,20 +132,15 @@ function extractChatCompletionsReasoning(
message: NonNullable['message']
): string | undefined {
if (!message) return undefined
- const msg = message as unknown as {
- reasoning_content?: string | null
- reasoning?: string | null
- reasoning_details?: Array<{ text?: string | null; summary?: string | null } | null> | null
- }
- if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0) {
- return msg.reasoning_content
+ if (typeof message.reasoning_content === 'string' && message.reasoning_content.length > 0) {
+ return message.reasoning_content
}
- if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0) {
- return msg.reasoning
+ if (typeof message.reasoning === 'string' && message.reasoning.length > 0) {
+ return message.reasoning
}
- if (Array.isArray(msg.reasoning_details)) {
- const joined = msg.reasoning_details
+ if (Array.isArray(message.reasoning_details)) {
+ const joined = message.reasoning_details
.map((d) => d?.text ?? d?.summary ?? '')
.filter((s): s is string => typeof s === 'string' && s.length > 0)
.join('\n')
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 */
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.