mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
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:
committed by
GitHub
parent
f1deb31359
commit
caa454aeca
@@ -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) {
|
||||
|
||||
+16
@@ -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', () => {
|
||||
|
||||
+32
-6
@@ -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)) {
|
||||
|
||||
+9
@@ -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',
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
|
||||
+3
@@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
+32
-1
@@ -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')
|
||||
|
||||
+7
-1
@@ -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-[""]'
|
||||
|
||||
+5
-16
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
+15
-3
@@ -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}
|
||||
|
||||
+174
@@ -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')
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+40
@@ -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)
|
||||
})
|
||||
})
|
||||
+22
@@ -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)
|
||||
}
|
||||
+19
-4
@@ -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 && (
|
||||
|
||||
+29
@@ -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
-1
@@ -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)', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user