fix(mothership): bug fixes (#5735)

* fix(mothership): credential connect names, highlighted line, knowledge of connection

* fix(mothership): webhook url is now visible to the mothership

* fix(mship): tool name audit

* fix(mship): hosted key data

* fix(mship): show icons on question exed out

* fix(mship): redis replay

* fix(mothership): description and incremental vfs both nuked

* fix(ci): fix build

* fix(mship): tool names

* fix(mship): stream handling
This commit is contained in:
Siddharth Ganesan
2026-07-17 11:15:58 -07:00
committed by GitHub
parent f1deb31359
commit caa454aeca
52 changed files with 2091 additions and 422 deletions
@@ -173,7 +173,7 @@ describe('GET /api/mothership/chats/[chatId]', () => {
expect(mockReadEvents).not.toHaveBeenCalled()
})
it('returns the live activeStreamId when redis confirms the lock', async () => {
it('returns the live activeStreamId with a status-only snapshot (no events)', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-live',
type: 'mothership',
@@ -185,15 +185,57 @@ describe('GET /api/mothership/chats/[chatId]', () => {
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' })
const previewSession = {
id: 'preview-1',
previewVersion: 1,
status: 'active',
updatedAt: '2026-05-11T12:00:00Z',
}
mockReadFilePreviewSessions.mockResolvedValueOnce([previewSession])
const response = await GET(createRequest('chat-live'), makeContext('chat-live'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.chat.activeStreamId).toBe('stream-live')
// Events are read only to synthesize the in-flight assistant turn for the
// initial paint; the client reconnects to the replay buffer for the rest.
// Status and preview sessions ARE shipped so hydration can gate the
// reconnect and seed the preview panel before the resume request lands.
expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0')
expect(body.chat.streamSnapshot).toBeDefined()
expect(body.chat.streamSnapshot.status).toBe('active')
expect(mockReadFilePreviewSessions).toHaveBeenCalledWith('stream-live')
expect(body.chat.streamSnapshot).toEqual({
events: [],
previewSessions: [previewSession],
status: 'active',
})
})
it('reports a terminal run status when the stream lock is still visible', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-finished',
type: 'mothership',
title: 'Finished',
messages: [],
resources: [],
conversationId: 'stream-finished',
createdAt: new Date('2026-05-11T12:00:00Z'),
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'complete' })
const response = await GET(createRequest('chat-finished'), makeContext('chat-finished'))
expect(response.status).toBe(200)
const body = await response.json()
// The run finished but the Redis lock hasn't cleared yet: the client
// must see the terminal status so it skips the reconnect entirely.
expect(body.chat.activeStreamId).toBe('stream-finished')
expect(body.chat.streamSnapshot).toEqual({
events: [],
previewSessions: [],
status: 'complete',
})
})
it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => {
@@ -50,7 +50,12 @@ export const GET = withRouteHandler(
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
// The Redis replay buffer is read here only to synthesize the in-flight
// assistant turn for the initial paint. The raw events are NOT shipped
// to the client: when `activeStreamId` is set, the client reconnects to
// the replay buffer (from seq 0) via the stream resume endpoint, which
// is the source of truth for streaming state.
let liveTurnSnapshot: {
events: StreamBatchEvent[]
previewSessions: FilePreviewSession[]
status: string
@@ -84,7 +89,7 @@ export const GET = withRouteHandler(
return null
})
streamSnapshot = {
liveTurnSnapshot = {
events: events.map(toStreamBatchEvent),
previewSessions,
status:
@@ -111,7 +116,7 @@ export const GET = withRouteHandler(
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: liveStreamId,
...(streamSnapshot ? { streamSnapshot } : {}),
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
})
return NextResponse.json({
@@ -124,7 +129,19 @@ export const GET = withRouteHandler(
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
// Events stay out of the payload (the resume endpoint replays them),
// but the client still needs the run status to skip reconnecting to
// an already-terminal stream, and the preview sessions to seed the
// file preview panel before the reconnect lands.
...(liveTurnSnapshot
? {
streamSnapshot: {
events: [],
previewSessions: liveTurnSnapshot.previewSessions,
status: liveTurnSnapshot.status,
},
}
: {}),
},
})
} catch (error) {
@@ -166,6 +166,22 @@ describe('divider Backspace', () => {
expect(selection.to).toBe(doc.content.size)
editor.destroy()
})
it('only paints a divider inside a range selection while the editor has focus', () => {
const editor = editorWith('<p>a</p><hr><p>b</p>')
const divider = editor.view.dom.querySelector('hr')
editor.commands.selectAll()
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false)
editor.view.dom.dispatchEvent(new FocusEvent('focus'))
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(true)
editor.view.dom.dispatchEvent(new FocusEvent('blur'))
expect(editor.state.selection.empty).toBe(false)
expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false)
editor.destroy()
})
})
describe('empty wrapped-block Backspace', () => {
@@ -20,6 +20,8 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
/** Item node types a list is built from, used to detect an empty item's position within its list. */
const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey<boolean>('richLeafSelectionFocus')
/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
function isInsideWrapper($from: ResolvedPos): boolean {
for (let depth = $from.depth - 1; depth >= 1; depth--) {
@@ -157,10 +159,11 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
* ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately
* in `./block-mover.ts`.)
*
* Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all),
* which the browser's native text highlight skips because leaves carry no text, and (b) flags the
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its
* otherwise-stray caret.
* Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g.
* select-all), which the browser's native text highlight skips because leaves carry no text; hiding
* that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the
* editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide
* its otherwise-stray caret.
*/
export const RichMarkdownKeymap = Extension.create({
name: 'richMarkdownKeymap',
@@ -231,11 +234,34 @@ export const RichMarkdownKeymap = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('richLeafSelectionHighlight'),
key: RICH_LEAF_SELECTION_FOCUS_KEY,
state: {
init: () => false,
apply(transaction, focused) {
const nextFocused = transaction.getMeta(RICH_LEAF_SELECTION_FOCUS_KEY)
return typeof nextFocused === 'boolean' ? nextFocused : focused
},
},
props: {
handleDOMEvents: {
focus(view) {
view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, true))
return false
},
blur(view) {
view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, false))
return false
},
},
decorations(state) {
const { selection } = state
if (selection.empty || selection instanceof NodeSelection) return null
if (
RICH_LEAF_SELECTION_FOCUS_KEY.getState(state) !== true ||
selection.empty ||
selection instanceof NodeSelection
) {
return null
}
const decorations: Decoration[] = []
state.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
if (SELECTABLE_LEAVES.has(node.type.name)) {
@@ -41,6 +41,15 @@ describe('ToolCallItem', () => {
expect(markup).not.toContain('Writing brief.md')
})
it('defensively applies the completed verb for every successful tool row', () => {
const markup = renderToStaticMarkup(
<ToolCallItem toolName='diff_workflows' displayTitle='Comparing workflows' status='success' />
)
expect(markup).toContain('Compared workflows')
expect(markup).not.toContain('Comparing workflows')
})
it('renders the owning integration icon for a resolved integration operation', () => {
vi.mocked(getBlockByToolName).mockReturnValueOnce({
name: 'Gmail',
@@ -7,7 +7,7 @@ import {
} from '@/lib/copilot/generated/tool-catalog-v1'
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display'
import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
import { getBareIconStyle } from '@/blocks/icon-color'
import { getBlockByToolName } from '@/blocks/registry'
import type { ToolCallStatus } from '../../../../types'
@@ -44,6 +44,8 @@ interface ToolCallItemProps {
* rewrite in `toToolData`, the past-tense flip is applied here on success.
* A `read` of a block or integration schema shows the block's brand icon
* inline next to its display name (e.g. the Gmail logo before "Read Gmail").
* The status-aware rewrite is repeated at this final rendering boundary so
* live, replayed, and directly-constructed rows cannot bypass completed verbs.
*/
export function ToolCallItem({
toolName,
@@ -98,10 +100,7 @@ export function ToolCallItem({
const isExecuting = resolveToolDisplayState(status) === 'spinner'
const liveTitle = liveWorkspaceFileTitle || displayTitle
const title =
status === 'success' && liveWorkspaceFileTitle
? (getToolCompletedTitle(liveTitle) ?? liveTitle)
: liveTitle
const title = getToolStatusDisplayTitle(liveTitle, status)
const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon
@@ -363,6 +363,7 @@ interface ChatContentProps {
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
/** Reports whether this segment is actively painting text or its own pending-tag indicator. */
@@ -374,6 +375,7 @@ function ChatContentInner({
isStreaming = false,
questionAnswers,
onOptionSelect,
onQuestionDismiss,
onWorkspaceResourceSelect,
onRevealStateChange,
onStreamActivityChange,
@@ -586,6 +588,7 @@ function ChatContentInner({
segment={group.segment}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
/>
)
})}
@@ -3,7 +3,7 @@
*/
import { act, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
formatQuestionAnswerMessage,
parseQuestionAnswerMessage,
@@ -89,6 +89,37 @@ describe('parseQuestionAnswerMessage', () => {
})
describe('QuestionDisplay', () => {
it('reports dismissal when the X hides the card', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onDismiss = vi.fn()
act(() => {
root.render(
createElement(QuestionDisplay, {
data: [QUESTIONS[0]],
onSelect: () => undefined,
onDismiss,
})
)
})
const dismissButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Dismiss')
)
expect(dismissButton).toBeDefined()
act(() => dismissButton?.click())
expect(onDismiss).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain(QUESTIONS[0].prompt)
act(() => root.unmount())
container.remove()
})
it('renders multi-select recap answers as separate, spaced rows', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
@@ -86,6 +86,8 @@ interface QuestionDisplayProps {
answers?: string[]
/** Sends the combined answer as a user message; undefined renders the div inert. */
onSelect?: (message: string) => void
/** Reports that the active card was dismissed so its message actions can return. */
onDismiss?: () => void
}
/**
@@ -103,6 +105,7 @@ export function QuestionDisplay({
data,
answers: transcriptAnswers,
onSelect,
onDismiss,
}: QuestionDisplayProps) {
const freeTextInputRef = useRef<HTMLInputElement>(null)
const freeTextCheckboxRef = useRef<HTMLButtonElement>(null)
@@ -292,7 +295,10 @@ export function QuestionDisplay({
<Button
type='button'
variant='ghost'
onClick={() => setPhase('dismissed')}
onClick={() => {
setPhase('dismissed')
onDismiss?.()
}}
className={cn(
ICON_BUTTON_CLASSES,
'before:absolute before:inset-[-14px] before:content-[""]'
@@ -72,17 +72,17 @@ describe('CredentialDisplay link tag', () => {
})
it('renders a working link for a real http(s) connect URL', () => {
const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo'
const url = 'https://sim.test/api/auth/oauth2/authorize?providerId=google-drive'
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'github',
provider: 'google-drive',
value: url,
})
const link = container.querySelector('a')
expect(link).not.toBeNull()
expect(link?.getAttribute('href')).toBe(url)
expect(container.textContent).toContain('Connect github')
expect(container.textContent).toContain('Connect Google Drive')
act(() => root.unmount())
})
@@ -98,17 +98,6 @@ describe('CredentialDisplay link tag', () => {
act(() => root.unmount())
})
it('does not query a credential for a plain connect URL', () => {
const { root } = renderCredentialLink({
type: 'link',
provider: 'github',
value: 'https://github.com/login/oauth/authorize?client_id=abc',
})
expect(mockUseWorkspaceCredential).toHaveBeenCalledWith(undefined)
act(() => root.unmount())
})
it('labels a reconnect URL with the credential display name', () => {
mockUseWorkspaceCredential.mockReturnValue({
data: { id: 'cred-1', displayName: "Justin's Gmail" },
@@ -125,7 +114,7 @@ describe('CredentialDisplay link tag', () => {
act(() => root.unmount())
})
it('falls back to the provider label while the reconnect credential is unresolved', () => {
it('falls back to the integration name while the reconnect credential is unresolved', () => {
const { container, root } = renderCredentialLink({
type: 'link',
provider: 'google-email',
@@ -133,7 +122,7 @@ describe('CredentialDisplay link tag', () => {
'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1',
})
expect(container.textContent).toContain('Reconnect google-email')
expect(container.textContent).toContain('Reconnect Gmail')
act(() => root.unmount())
})
})
@@ -20,6 +20,7 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question'
import type {
@@ -516,6 +517,7 @@ interface SpecialTagsProps {
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
}
@@ -527,6 +529,7 @@ export function SpecialTags({
segment,
questionAnswers,
onOptionSelect,
onQuestionDismiss,
onWorkspaceResourceSelect,
}: SpecialTagsProps) {
switch (segment.type) {
@@ -544,7 +547,12 @@ export function SpecialTags({
return <WorkspaceResourceDisplay data={segment.data} onSelect={onWorkspaceResourceSelect} />
case 'question':
return (
<QuestionDisplay data={segment.data} answers={questionAnswers} onSelect={onOptionSelect} />
<QuestionDisplay
data={segment.data}
answers={questionAnswers}
onSelect={onOptionSelect}
onDismiss={onQuestionDismiss}
/>
)
default:
return null
@@ -878,9 +886,13 @@ function CredentialLinkDisplay({ data }: { data: CredentialTagData }) {
// render it as a clickable link when it resolves to a real http(s) URL.
if (!data.value || !isSafeHttpUrl(data.value)) return null
const Icon = getCredentialIcon(data.provider) ?? LockIcon
const integrationName =
getServiceConfigByProviderId(data.provider)?.name ??
OAUTH_PROVIDERS[data.provider.toLowerCase()]?.name ??
data.provider
const label = reconnectCredentialId
? `Reconnect ${reconnectCredential?.displayName ?? data.provider}`
: `Connect ${data.provider}`
? `Reconnect ${reconnectCredential?.displayName ?? integrationName}`
: `Connect ${integrationName}`
return (
<a
href={data.value}
@@ -2,6 +2,15 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools'
import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display'
import {
createTurnModel,
reduceEvent,
} from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model'
import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize'
import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
@@ -36,6 +45,49 @@ function mainToolCall(id: string, name: string): ContentBlock {
return { type: 'tool_call', toolCall: { id, name, status: 'success' }, timestamp: 1 }
}
function representativeToolArgs(entry: ToolCatalogEntry): Record<string, unknown> {
const args: Record<string, unknown> = {}
if (!entry.parameters || typeof entry.parameters !== 'object') return args
const properties = (entry.parameters as { properties?: unknown }).properties
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args
for (const [key, rawSchema] of Object.entries(properties)) {
if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue
const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown }
if (schema.default !== undefined) {
args[key] = schema.default
} else if (Array.isArray(schema.enum) && schema.enum.length > 0) {
args[key] = schema.enum[0]
} else if (schema.type === 'boolean') {
args[key] = true
} else if (schema.type === 'object') {
args[key] = {}
}
}
return args
}
function toolEnvelope(
seq: number,
payload: Record<string, unknown>,
agentId = 'deploy'
): PersistedStreamEventEnvelope {
return {
v: 1,
seq,
ts: new Date(seq).toISOString(),
stream: { streamId: 'stream-1', cursor: String(seq) },
type: 'tool',
payload,
scope: {
lane: 'subagent',
spanId: `${agentId}-span`,
parentSpanId: 'main',
agentId,
},
} as PersistedStreamEventEnvelope
}
describe('parseBlocks span-identity tree', () => {
it('refines a completed credential rename with its previous and new names', () => {
const segments = parseBlocks([
@@ -388,6 +440,128 @@ describe('completed tool titles', () => {
)
})
it('renders the completed deployment action and deployment type', () => {
expect(
firstToolTitle([
{
type: 'tool_call',
toolCall: {
id: 'undeploy-api',
name: 'deploy_api',
status: 'success',
params: { action: 'undeploy' },
},
timestamp: 1,
},
])
).toBe('Undeployed API')
expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_mcp')])).toBe('Deployed MCP tool')
})
it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => {
const model = createTurnModel()
reduceEvent(
model,
toolEnvelope(1, {
phase: 'call',
toolCallId: 'diff-1',
toolName: 'diff_workflows',
arguments: { ref1: 'live', ref2: 'draft' },
})
)
reduceEvent(
model,
toolEnvelope(2, {
phase: 'result',
toolCallId: 'diff-1',
toolName: 'diff_workflows',
success: true,
status: 'success',
output: { differences: [] },
})
)
expect(firstToolTitle(modelToContentBlocks(model))).toBe('Compared workflows')
})
it('humanizes an internal read target through the full wire lifecycle', () => {
const model = createTurnModel()
reduceEvent(
model,
toolEnvelope(
1,
{
phase: 'call',
toolCallId: 'read-oauth-integrations',
toolName: 'read',
arguments: { path: 'environment/oauth-integrations.json' },
},
'auth'
)
)
reduceEvent(
model,
toolEnvelope(
2,
{
phase: 'result',
toolCallId: 'read-oauth-integrations',
toolName: 'read',
success: true,
status: 'success',
output: {},
},
'auth'
)
)
expect(firstToolTitle(modelToContentBlocks(model))).toBe('Read OAuth integrations')
})
it('renders the completed title through the full wire lifecycle for every visible tool', () => {
const hiddenToolNames = getHiddenToolNames()
const failures: string[] = []
for (const [toolName, entry] of Object.entries(TOOL_CATALOG)) {
// Internal subagent dispatches become agent groups, and hidden plumbing
// is intentionally suppressed; neither produces a visible tool row.
if (entry.internal || hiddenToolNames.has(toolName)) continue
const args = representativeToolArgs(entry)
const model = createTurnModel()
reduceEvent(
model,
toolEnvelope(1, {
phase: 'call',
toolCallId: `${toolName}-1`,
toolName,
arguments: args,
})
)
reduceEvent(
model,
toolEnvelope(2, {
phase: 'result',
toolCallId: `${toolName}-1`,
toolName,
success: true,
status: 'success',
output: {},
})
)
const presentTitle = getToolDisplayTitle(toolName, args)
const expectedTitle = getToolStatusDisplayTitle(presentTitle, 'success')
const actualTitle = firstToolTitle(modelToContentBlocks(model))
if (actualTitle !== expectedTitle) {
failures.push(`${toolName}: expected ${expectedTitle}, received ${actualTitle}`)
}
}
expect(failures).toEqual([])
})
it('keeps present tense while executing and on error', () => {
expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs')
expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs')
@@ -6,8 +6,8 @@ import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import {
getToolCompletedTitle,
getToolDisplayTitle,
getToolStatusDisplayTitle,
humanizeToolName,
} from '@/lib/copilot/tools/tool-display'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -169,10 +169,7 @@ function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
const resolvedTitle =
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
const displayTitle =
tc.status === 'success'
? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle)
: resolvedTitle
const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status)
return {
id: tc.id,
@@ -770,6 +767,7 @@ interface MessageContentProps {
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onPhaseChange?: (phase: MessagePhase) => void
}
@@ -779,6 +777,7 @@ function MessageContentInner({
isStreaming = false,
questionAnswers,
onOptionSelect,
onQuestionDismiss,
onPhaseChange,
}: MessageContentProps) {
const { onWorkspaceResourceSelect } = useChatSurface()
@@ -867,6 +866,7 @@ function MessageContentInner({
})}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
@@ -0,0 +1,40 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
describe('shouldShowAssistantMessageActions', () => {
it('restores actions when the trailing question card is dismissed', () => {
expect(
shouldShowAssistantMessageActions({
phase: 'settled',
hasContent: true,
endsWithQuestion: true,
questionDismissed: true,
})
).toBe(true)
})
it('keeps actions hidden for an active or answered trailing question card', () => {
expect(
shouldShowAssistantMessageActions({
phase: 'settled',
hasContent: true,
endsWithQuestion: true,
questionDismissed: false,
})
).toBe(false)
})
it('waits for the message to settle before restoring actions', () => {
expect(
shouldShowAssistantMessageActions({
phase: 'streaming',
hasContent: true,
endsWithQuestion: true,
questionDismissed: true,
})
).toBe(false)
})
})
@@ -0,0 +1,22 @@
import type { MessagePhase } from '@/app/workspace/[workspaceId]/home/components/message-content'
interface AssistantMessageActionsVisibility {
phase: MessagePhase
hasContent: boolean
endsWithQuestion: boolean
questionDismissed: boolean
}
/**
* Question cards replace the normal message actions while they are active or
* answered. Dismissing an active card restores those actions for the settled
* assistant message underneath it.
*/
export function shouldShowAssistantMessageActions({
phase,
hasContent,
endsWithQuestion,
questionDismissed,
}: AssistantMessageActionsVisibility): boolean {
return phase === 'settled' && hasContent && (!endsWithQuestion || questionDismissed)
}
@@ -43,6 +43,7 @@ import type {
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import type { ChatContext } from '@/stores/panel'
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
interface MothershipChatProps {
messages: ChatMessage[]
@@ -196,6 +197,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
const trimmedContent = message.content?.trim() ?? ''
const [phase, setPhase] = useState<MessagePhase>(isStreaming ? 'streaming' : 'settled')
const [dismissedQuestionTag, setDismissedQuestionTag] = useState<string | null>(null)
const onAnimatingChangeRef = useRef(onAnimatingChange)
onAnimatingChangeRef.current = onAnimatingChange
@@ -212,11 +214,23 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
return null
}
// A message that ends with a question card is an input surface, not a
// reactable assistant turn: no copy/thumbs row beneath the card, whether
// the card is awaiting answers or collapsed to its recap.
// A trailing question card replaces the copy/thumbs row while active or
// answered. Its raw tag is the dismissal identity so a later question added
// to the same turn cannot inherit an earlier card's dismissed state.
const endsWithQuestion = trimmedContent.endsWith('</question>')
const showActions = phase === 'settled' && !endsWithQuestion && (message.content || hasAnyBlocks)
const questionTag = endsWithQuestion
? trimmedContent.slice(trimmedContent.lastIndexOf('<question>'))
: null
const questionDismissed = questionTag !== null && dismissedQuestionTag === questionTag
const handleQuestionDismiss = () => {
if (questionTag) setDismissedQuestionTag(questionTag)
}
const showActions = shouldShowAssistantMessageActions({
phase,
hasContent: Boolean(message.content) || hasAnyBlocks,
endsWithQuestion,
questionDismissed,
})
return (
<div className={rowClassName}>
@@ -226,6 +240,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
isStreaming={isStreaming}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={handleQuestionDismiss}
onPhaseChange={setPhase}
/>
{showActions && (
@@ -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(
<GenericResourceContent
data={{
entries: [
{
toolCallId: 'diff-1',
toolName: 'diff_workflows',
displayTitle: 'Comparing workflows',
status: 'success',
result: { success: true, output: { differences: [] } },
},
],
}}
/>
)
expect(markup).toContain('Compared workflows')
expect(markup).not.toContain('Comparing workflows')
})
})
@@ -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) {
/>
)}
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{entry.displayTitle}
{getToolStatusDisplayTitle(entry.displayTitle, entry.status)}
</span>
{entry.status === 'error' && (
<span className='ml-auto text-[12px] text-[var(--text-error)]'>Error</span>
@@ -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',
currentContent: '',
currentBlocks: [{ type: 'tool_call', toolCall: { id: 't1', name: 'grep' } } as ContentBlock],
})
expect(result).toEqual({
afterCursor: '4',
preserveExistingState: true,
source: 'live',
})
})
it('replays the buffer from seq 0 when a nonzero cursor has no live in-memory state', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
cachedLiveAssistant: null,
currentContent: '',
currentBlocks: [],
})
expect(result).toEqual({
afterCursor: '0',
content: '',
contentBlocks: [],
preserveExistingState: false,
source: 'reset',
})
})
it('resets when cached live content diverges from the local prefix', () => {
const result = selectReconnectReplayState({
afterCursor: '4',
cachedLiveAssistant: {
content: 'Goodbye world',
contentBlocks: [{ type: 'text', content: 'Goodbye world' }],
},
currentContent: 'Hello',
currentBlocks: [{ type: 'text', content: 'Hello' }],
})
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',
})
@@ -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<ChatMessage, 'content' | 'contentBlocks'> | 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<string> {
@@ -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<MothershipChatHistory>(
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<ReturnType<typeof attachToExistingStreamRef.current>> | 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,
@@ -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)', () => {
+14 -6
View File
@@ -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 } : {}),
}))
@@ -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',
@@ -221,10 +221,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
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<string, ToolRuntimeSchemaEntry> = {
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',
@@ -113,7 +113,6 @@ export interface VfsSnapshotV1Table {
* via the `definition` "VfsSnapshotV1Workflow".
*/
export interface VfsSnapshotV1Workflow {
description?: string
folderPath?: string
id: string
isDeployed?: boolean
@@ -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'
)
})
@@ -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 =
@@ -30,6 +30,22 @@ describe('getCopilotToolDescription', () => {
).toBe('Search for brands by company name <note>API key is hosted by Sim.</note>')
})
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 <note>API key is hosted by Sim when hosted-key support applies to the selected configuration.</note>'
)
})
it.concurrent('uses the fallback name when no description exists', () => {
expect(
getCopilotToolDescription(
+9 -2
View File
@@ -1,6 +1,8 @@
import type { ToolConfig } from '@/tools/types'
const HOSTED_API_KEY_NOTE = '<note>API key is hosted by Sim.</note>'
const CONDITIONAL_HOSTED_API_KEY_NOTE =
'<note>API key is hosted by Sim when hosted-key support applies to the selected configuration.</note>'
const EMAIL_TAGLINE_NOTE =
'<important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
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 (
@@ -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
@@ -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<string, unknown>
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()
@@ -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,
@@ -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[]
}
@@ -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<typeof executeCreateWorkflow>[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({
@@ -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<ToolCallResult> {
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
@@ -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')
@@ -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<string, any> = {}
@@ -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.
@@ -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<string, unknown> {
const args: Record<string, unknown> = {}
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<string, unknown>)[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')
})
})
+355 -10
View File
@@ -43,6 +43,32 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]):
return firstStringArg(parent as Record<string, unknown>, ...keys)
}
function recordArg(args: ToolArgs, key: string): Record<string, unknown> | undefined {
const value = args?.[key]
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, string> = {
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<string, unknown>).files
if (!Array.isArray(files)) return ''
for (const file of files) {
if (!file || typeof file !== 'object') continue
const mode = stringArg(file as Record<string, unknown>, 'mode')
if (mode) return mode
}
return ''
}
function createFileTitle(args: ToolArgs): string {
const nestedArgs =
args?.args && typeof args.args === 'object' ? (args.args as Record<string, unknown>) : 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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, unknown>, '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<string, unknown> =>
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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,
}
@@ -265,16 +502,34 @@ const ACRONYM_CASING: Record<string, string> = {
ai: 'AI',
}
/**
* 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 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, 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 {
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))
.join(' ')
return humanizeDisplayIdentifier(name)
}
/**
@@ -289,6 +544,67 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
}
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<string, string> = {
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<string, string> = {
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
}
+169 -1
View File
@@ -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', () => {
+138 -18
View File
@@ -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<string, ToolConfig>
}
/**
* 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<string, unknown> {
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<string, unknown> {
/**
* 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<string, VfsToolAuth> = {}
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<ComponentSerializationOptions, 'hosted'>
): 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,
+12 -19
View File
@@ -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<string, string> {
// 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<string, ToolConfig>()
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<string, { provider: string; operations: string[] }>()
const apiKeyServices = new Map<string, { params: string[]; operations: string[] }>()
// Integration tools come from the shared exposed-tool set (latest version of
// each operation owned by a visible block), the same set used to build the
// deferred callable tools — so discovery and execution can never drift.
for (const exposedTool of getExposedIntegrationTools()) {
for (const exposedTool of exposedTools) {
const { config: tool, service, operation, blockType, preview } = exposedTool
const path = `components/integrations/${service}/${operation}.json`
files.set(path, serializeIntegrationSchema(tool))
@@ -261,19 +268,6 @@ function getStaticComponentFiles(): Map<string, string> {
} 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<string, string> {
)
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,
@@ -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 = {
@@ -79,11 +79,17 @@ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise<void> {
}
/**
* 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.
*/
@@ -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<string, unknown>): 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)
})
})
@@ -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)
@@ -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
}
+12 -11
View File
@@ -13,6 +13,12 @@ interface ChatCompletionLike {
message?: {
content?: string | null
tool_calls?: Array<ChatCompletionToolCallLike> | 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<ChatCompletionLike['choices'][number]>['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')
+16
View File
@@ -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<P>(condition: ToolHostingCondition): ToolHostingPredicate<P> {
const predicate = ((params: P) => {
const value = (params as unknown as Record<string, unknown>)[condition.field]
if (condition.operator === 'equals') return value === condition.value
return condition.values.includes(value as string | number | boolean | null)
}) as ToolHostingPredicate<P>
predicate.condition = condition
return predicate
}
+6 -1
View File
@@ -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<ImageGenerationParams, ImageGeneratio
},
hosting: {
enabled: (params) => params.provider === 'falai',
enabled: hostedKeyEnabledWhen<ImageGenerationParams>({
field: 'provider',
operator: 'equals',
value: 'falai',
}),
envKeyPrefix: 'FALAI_API_KEY',
apiKeyParam: 'apiKey',
byokProviderId: 'falai',
+23 -2
View File
@@ -303,6 +303,23 @@ interface CustomPricing<P = Record<string, unknown>> {
/** Union of all pricing models */
export type ToolHostingPricing<P = Record<string, unknown>> = PerRequestPricing | CustomPricing<P>
export type ToolHostingCondition =
| {
field: string
operator: 'equals'
value: string | number | boolean | null
}
| {
field: string
operator: 'one_of'
values: Array<string | number | boolean | null>
}
export type ToolHostingPredicate<P> = ((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.
@@ -324,16 +341,20 @@ export type ToolHostingPricing<P = Record<string, unknown>> = 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<P = Record<string, unknown>> {
/** Optional predicate for tools where hosted keys only apply to some parameter combinations. */
enabled?: (params: P) => boolean
enabled?: ToolHostingPredicate<P>
/**
* 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 */
+10
View File
@@ -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.