Align Copilot tools and resource handling

This commit is contained in:
Siddharth Ganesan
2026-08-13 13:03:23 -07:00
parent 3738347b05
commit 73ff4b7568
115 changed files with 3228 additions and 2406 deletions
@@ -4,10 +4,10 @@ import { ShimmerText } from '@/components/ui'
import {
BrowserRequestTakeover,
CallIntegrationTool,
PrepareFileEdit,
Read as ReadTool,
Terminal as TerminalTool,
Wait as WaitTool,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
@@ -138,7 +138,7 @@ export function ToolCallItem({
}, [toolName, params, streamingArgs])
const liveWorkspaceFileTitle = useMemo(() => {
if (toolName !== WorkspaceFile.id || !streamingArgs) return null
if (toolName !== PrepareFileEdit.id || !streamingArgs) return null
const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/)
if (!titleMatch?.[1]) return null
const opMatch = streamingArgs.match(/"operation"\s*:\s*"(\w+)"/)
@@ -135,7 +135,7 @@ describe('parseBlocks span-identity tree', () => {
subagentStart('workflow', 'S1', 'main'),
subagentToolCall('t1', 'create_workflow', 'S1', 'workflow'),
subagentStart('deploy', 'S2', 'S1'),
subagentToolCall('t2', 'check_deployment_status', 'S2', 'deploy'),
subagentToolCall('t2', 'get_deployment_status', 'S2', 'deploy'),
]
const segments = parseBlocks(blocks)
@@ -185,9 +185,9 @@ describe('parseBlocks span-identity tree', () => {
it('creates distinct groups for repeated deploy invocations (no collision)', () => {
const blocks: ContentBlock[] = [
subagentStart('deploy', 'S2', 'main'),
subagentToolCall('t1', 'deploy_api', 'S2', 'deploy'),
subagentToolCall('t1', 'deploy_as_api', 'S2', 'deploy'),
subagentStart('deploy', 'S4', 'main'),
subagentToolCall('t2', 'deploy_api', 'S4', 'deploy'),
subagentToolCall('t2', 'deploy_as_api', 'S4', 'deploy'),
]
const segments = parseBlocks(blocks)
@@ -311,7 +311,7 @@ describe('parseBlocks span-identity tree', () => {
it('absorbs the dispatch tool of a nested file subagent from its parent span group', () => {
const blocks: ContentBlock[] = [
subagentStart('workflow', 'S1', 'main'),
subagentToolCall('t1', 'workspace_file', 'S1', 'workflow'),
subagentToolCall('t1', 'prepare_file_edit', 'S1', 'workflow'),
{ type: 'subagent', content: 'file', spanId: 'S2', parentSpanId: 'S1', timestamp: 2 },
{ type: 'subagent_text', content: 'writing', spanId: 'S2', timestamp: 3 },
]
@@ -321,7 +321,7 @@ describe('parseBlocks span-identity tree', () => {
const workflow = segments[0]
if (workflow.type !== 'agent_group') throw new Error('expected workflow group')
// The workspace_file dispatch tool is absorbed (not shown as a sibling tool);
// The prepare_file_edit dispatch tool is absorbed (not shown as a sibling tool);
// only the nested file subagent remains under workflow.
expect(workflow.items.some((item) => item.type === 'tool')).toBe(false)
const nested = workflow.items.find((item) => item.type === 'agent_group')
@@ -476,7 +476,7 @@ describe('completed tool titles', () => {
type: 'tool_call',
toolCall: {
id: 'undeploy-api',
name: 'deploy_api',
name: 'deploy_as_api',
status: 'success',
params: { action: 'undeploy' },
},
@@ -485,7 +485,7 @@ describe('completed tool titles', () => {
])
).toBe('Undeployed API')
expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_mcp')])).toBe('Deployed MCP tool')
expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe('Deployed MCP tool')
})
it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => {
@@ -755,7 +755,7 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
const blocks: ContentBlock[] = [
{
type: 'tool_call',
toolCall: { id: 'dispatch-1', name: 'workspace_file', status: 'executing' },
toolCall: { id: 'dispatch-1', name: 'prepare_file_edit', status: 'executing' },
timestamp: 1,
},
{
@@ -785,7 +785,7 @@ describe('deriveThinkingLabel', () => {
it('shows Dispatching for the dispatch call, then yields to the opened lane', () => {
expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…')
expect(deriveThinkingLabel([mainToolCall('t1', 'workspace_file')])).toBe('Dispatching…')
expect(deriveThinkingLabel([mainToolCall('t1', 'prepare_file_edit')])).toBe('Dispatching…')
expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…')
expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBeNull()
})
@@ -11,7 +11,7 @@ import {
useState,
} from 'react'
import { cn } from '@sim/emcn'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
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'
@@ -127,7 +127,7 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
* group is absorbed so it doesn't render as a separate Mothership entry.
*/
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
[FILE_SUBAGENT_ID]: WorkspaceFile.id,
[FILE_SUBAGENT_ID]: PrepareFileEdit.id,
}
function isToolResultRead(params?: Record<string, unknown>): boolean {
@@ -33,19 +33,19 @@ const TOOL_ICONS: Record<string, IconComponent> = {
mv: FolderCode,
cp: Layout,
mkdir: FolderCode,
search_online: Search,
scrape_page: Search,
get_page_contents: Search,
web_search: Search,
web_scrape: Search,
web_fetch: Search,
search_library_docs: Library,
manage_mcp_tool: Settings,
manage_mcp_connection: Settings,
manage_skill: Asterisk,
user_memory: Database,
function_execute: TerminalWindow,
run_function: TerminalWindow,
run_code: TerminalWindow,
superagent: Blimp,
user_table: TableIcon,
workspace_file: File,
edit_content: File,
prepare_file_edit: File,
apply_file_edit: File,
create_workflow: Layout,
edit_workflow: Pencil,
workflow: Hammer,
@@ -256,6 +256,7 @@ export const ResourceContent = memo(function ResourceContent({
tableId={resource.id}
embedded
viewsEnabled={tableViewsEnabled}
initialViewId={resource.viewId}
/>
)
@@ -4,7 +4,7 @@ import {
MothershipStreamV1ToolPhase,
MothershipStreamV1ToolStatus,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1'
import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract'
import {
extractResourcesFromToolResult,
@@ -77,7 +77,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id)
}
if ((name === 'edit_content' || name === WorkspaceFile.id) && isSuccess) {
if ((name === ApplyFileEdit.id || name === PrepareFileEdit.id) && isSuccess) {
const out = output as Record<string, unknown> | undefined
const editData =
out && typeof out.data === 'object' && out.data !== null
@@ -100,17 +100,20 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
deps.onToolResultRef.current?.(name, isSuccess, output)
const workspaceFileOperation =
name === WorkspaceFile.id && typeof params?.operation === 'string'
name === PrepareFileEdit.id && typeof params?.operation === 'string'
? params.operation
: undefined
const shouldKeepWorkspacePreviewOpen =
name === WorkspaceFile.id &&
name === PrepareFileEdit.id &&
(workspaceFileOperation === 'append' ||
workspaceFileOperation === 'update' ||
workspaceFileOperation === 'patch')
if ((name === WorkspaceFile.id || name === 'edit_content') && !shouldKeepWorkspacePreviewOpen) {
if (name === WorkspaceFile.id) {
if (
(name === PrepareFileEdit.id || name === ApplyFileEdit.id) &&
!shouldKeepWorkspacePreviewOpen
) {
if (name === PrepareFileEdit.id) {
deps.removePreviewSessionImmediate(node.id)
}
const fileResource = extractedResources.find((r) => r.type === 'file')
@@ -126,7 +129,7 @@ function runToolResultSideEffects(ctx: StreamLoopContext, node: ToolNode): void
/**
* Side effects for tool events. State (the tool node, its status, args, and the
* edit_content row merge) is owned by `reduceEvent`; this handler routes preview
* apply_file_edit row merge) is owned by `reduceEvent`; this handler routes preview
* phases, fires client workflow tools, and runs result side effects, then
* flushes the model-derived snapshot.
*/
@@ -2,30 +2,30 @@ import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import {
CallIntegrationTool,
CrawlWebsite,
CreateFile,
CreateEmptyFile,
CreateWorkflow,
DeployApi,
DeployChat,
DeployMcp,
DeployAsApi,
DeployAsChat,
DeployAsMcp,
EditWorkflow,
FunctionExecute,
Glob,
Grep,
ManageCredential,
ManageCustomTool,
ManageMcpTool,
ManageMcpConnection,
ManageSkill,
PrepareFileEdit,
PrepareFileEditOperation,
QueryLogs,
Redeploy,
Rm,
RunFromBlock,
RunFunction,
RunWorkflow,
RunWorkflowUntilBlock,
ScrapePage,
SearchOnline,
WorkspaceFile,
WorkspaceFileOperation,
WebCrawl,
WebScrape,
WebSearch,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args'
import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display'
@@ -40,9 +40,9 @@ const logger = createLogger('StreamHelpers')
export const FILE_SUBAGENT_ID = 'file'
export const DEPLOY_TOOL_NAMES: Set<string> = new Set([
DeployApi.id,
DeployChat.id,
DeployMcp.id,
DeployAsApi.id,
DeployAsChat.id,
DeployAsMcp.id,
Redeploy.id,
])
@@ -139,13 +139,13 @@ function resolveWorkspaceFileDisplayTitle(
let verb = 'Writing'
switch (operation) {
case WorkspaceFileOperation.append:
case PrepareFileEditOperation.append:
verb = 'Adding'
break
case WorkspaceFileOperation.patch:
case PrepareFileEditOperation.patch:
verb = 'Editing'
break
case WorkspaceFileOperation.update:
case PrepareFileEditOperation.update:
verb = 'Writing'
break
}
@@ -270,11 +270,11 @@ export function resolveStreamingToolDisplayTitle(
name: string,
streamingArgs: string
): string | undefined {
if (name === FunctionExecute.id) {
if (name === RunFunction.id) {
return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title'))
}
if (name === WorkspaceFile.id) {
if (name === PrepareFileEdit.id) {
return resolveWorkspaceFileDisplayTitle(
matchStreamingStringArg(streamingArgs, 'operation'),
matchStreamingStringArg(streamingArgs, 'title'),
@@ -282,7 +282,7 @@ export function resolveStreamingToolDisplayTitle(
)
}
if (name === CreateFile.id) {
if (name === CreateEmptyFile.id) {
const target =
matchStreamingStringArg(streamingArgs, 'path') ??
matchStreamingStringArg(streamingArgs, 'fileName')
@@ -299,7 +299,7 @@ export function resolveStreamingToolDisplayTitle(
return workflowId ? resolveToolDisplayTitle(name, { workflowId }) : undefined
}
if (name === SearchOnline.id) {
if (name === WebSearch.id) {
const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle')
return toolTitle ? `Searching online for ${toolTitle}` : undefined
}
@@ -349,12 +349,12 @@ export function resolveStreamingToolDisplayTitle(
return toolTitle ? `Deleting ${toolTitle}` : undefined
}
if (name === ScrapePage.id) {
if (name === WebScrape.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Scraping ${url}` : undefined
}
if (name === CrawlWebsite.id) {
if (name === WebCrawl.id) {
const url = matchStreamingStringArg(streamingArgs, 'url')
return url ? `Crawling ${url}` : undefined
}
@@ -363,7 +363,7 @@ export function resolveStreamingToolDisplayTitle(
return resolveStreamingManagedResourceTitle(name, streamingArgs, ['toolTitle', 'title', 'name'])
}
if (name === ManageMcpTool.id) {
if (name === ManageMcpConnection.id) {
return resolveStreamingManagedResourceTitle(name, streamingArgs, [
'serverName',
'name',
@@ -68,7 +68,7 @@ describe('streaming resource titles', () => {
})
// A main-agent file delegation: trigger tool (main lane), subagent span, inner
// workspace_file, span end, delegation result.
// prepare_file_edit, span end, delegation result.
function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
const sub: Scope = {
lane: 'subagent',
@@ -89,13 +89,13 @@ function fileDelegationEvents(): PersistedStreamEventEnvelope[] {
env(
4,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
5,
'tool',
{ phase: 'result', toolCallId: 'wf-1', toolName: 'workspace_file', success: true },
{ phase: 'result', toolCallId: 'wf-1', toolName: 'prepare_file_edit', success: true },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -124,7 +124,7 @@ describe('modelToContentBlocks', () => {
expect(trigger?.toolCall?.status).toBe('success')
const innerTool = blocksByType(blocks, 'tool_call').find(
(b) => b.toolCall?.name === 'workspace_file'
(b) => b.toolCall?.name === 'prepare_file_edit'
)
expect(innerTool?.spanId).toBe('S1')
expect(innerTool?.toolCall?.calledBy).toBe('file')
@@ -220,7 +220,7 @@ describe('modelToContentBlocks', () => {
env(
4,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
env(
@@ -233,7 +233,7 @@ describe('modelToContentBlocks', () => {
])
)
const types = blocks.map((b) => b.type)
const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'workspace_file')
const innerIdx = blocks.findIndex((b) => b.toolCall?.name === 'prepare_file_edit')
const endIdx = types.indexOf('subagent_end')
const afterIdx = blocks.findIndex((b) => b.type === 'text' && b.content === 'after')
// subagent_end sits after the inner work and before the trailing main text — no sibling jumps.
@@ -275,7 +275,7 @@ describe('modelToContentBlocks', () => {
env(
3,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
@@ -309,7 +309,7 @@ describe('modelToContentBlocks', () => {
env(1, 'tool', {
phase: 'call',
toolCallId: 'wf',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
arguments: { operation: 'create', title: 'My Doc' },
}),
])
@@ -322,7 +322,7 @@ describe('modelToContentBlocks', () => {
const blocks = modelToContentBlocks(build(fileDelegationEvents()))
const startIdx = blocks.findIndex((b) => b.type === 'subagent')
const innerIdx = blocks.findIndex(
(b) => b.type === 'tool_call' && b.toolCall?.name === 'workspace_file'
(b) => b.type === 'tool_call' && b.toolCall?.name === 'prepare_file_edit'
)
const endIdx = blocks.findIndex((b) => b.type === 'subagent_end')
expect(startIdx).toBeGreaterThanOrEqual(0)
@@ -425,7 +425,7 @@ describe('contentBlocksToModel round-trip', () => {
env(
3,
'tool',
{ phase: 'call', toolCallId: 'wf-1', toolName: 'workspace_file' },
{ phase: 'call', toolCallId: 'wf-1', toolName: 'prepare_file_edit' },
{ lane: 'subagent', spanId: 'S1' }
),
])
@@ -144,17 +144,17 @@ describe('reduceEvent — tool lifecycle', () => {
it('accumulates streaming args across deltas', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
argumentsDelta: '{"a":',
}),
envelope(3, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
argumentsDelta: '1}',
}),
])
@@ -164,11 +164,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('clears streamingArgs once the result settles the tool', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
phase: 'args_delta',
toolCallId: 'tc-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
argumentsDelta: '{"operation":"create"',
}),
toolResult(3, 'tc-1', true),
@@ -213,11 +213,11 @@ describe('reduceEvent — tool lifecycle', () => {
it('ignores preview phases (decoupled from tool status)', () => {
const m = apply([
toolCall(1, 'tc-1', 'workspace_file'),
toolCall(1, 'tc-1', 'prepare_file_edit'),
envelope(2, 'tool', {
previewPhase: 'file_preview_content',
toolCallId: 'tc-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
content: 'x',
contentMode: 'delta',
fileName: 'f',
@@ -270,8 +270,8 @@ describe('reduceEvent — subagent lifecycle', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-a'),
spanStart(2, 'S2', 'file', 'tc-b'),
toolCall(3, 'wf-a', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
toolCall(4, 'wf-b', 'workspace_file', { lane: 'subagent', spanId: 'S2' }),
toolCall(3, 'wf-a', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
toolCall(4, 'wf-b', 'prepare_file_edit', { lane: 'subagent', spanId: 'S2' }),
toolResult(5, 'wf-a', true),
spanEnd(6, 'S1', 'file'),
toolResult(7, 'wf-b', true),
@@ -327,7 +327,7 @@ describe('reduceEvent — idempotency', () => {
it('rebuilds the identical model when replayed into a fresh model', () => {
const events = [
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
toolCall(2, 'wf', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
toolResult(3, 'wf', true),
spanEnd(4, 'S1', 'file'),
complete(5),
@@ -340,42 +340,42 @@ describe('reduceEvent — idempotency', () => {
})
})
describe('reduceEvent — edit_content row merge', () => {
it('folds an edit_content write into its span workspace_file row', () => {
describe('reduceEvent — apply_file_edit row merge', () => {
it('folds an apply_file_edit write into its span prepare_file_edit row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
toolCall(4, 'ec-1', 'edit_content', sub),
toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
// No separate edit_content node; the workspace_file row reopened for the edit.
// No separate apply_file_edit node; the prepare_file_edit row reopened for the edit.
expect(m.nodes.has('ec-1')).toBe(false)
expect(tool(m, 'wf-1').status).toBe('running')
expect(m.toolAlias.get('ec-1')).toBe('wf-1')
})
it('settles the merged row on the edit_content result', () => {
it('settles the merged row on the apply_file_edit result', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
toolCall(3, 'ec-1', 'edit_content', sub),
toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolCall(3, 'ec-1', 'apply_file_edit', sub),
toolResult(4, 'ec-1', true, undefined, sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(m.nodes.has('ec-1')).toBe(false)
})
it('folds an edit_content result that raced ahead of its call into the merged row', () => {
it('folds an apply_file_edit result that raced ahead of its call into the merged row', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', sub),
// Result for edit_content arrives BEFORE its call (buffered under ec-1)...
toolCall(2, 'wf-1', 'prepare_file_edit', sub),
// Result for apply_file_edit arrives BEFORE its call (buffered under ec-1)...
toolResult(3, 'ec-1', true, undefined, sub),
// ...then the call lands and aliases ec-1 -> wf-1, draining the buffer.
toolCall(4, 'ec-1', 'edit_content', sub),
toolCall(4, 'ec-1', 'apply_file_edit', sub),
])
expect(tool(m, 'wf-1').status).toBe('success')
expect(tool(m, 'wf-1').result?.success).toBe(true)
@@ -386,13 +386,13 @@ describe('reduceEvent — edit_content row merge', () => {
const sub: Scope = { lane: 'subagent', spanId: 'S1' }
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
// Section 1: the workspace_file row is reopened by its edit_content, but the
// Section 1: the prepare_file_edit row is reopened by its apply_file_edit, but the
// edit's closing result is reordered/dropped — wf-1 is left running.
toolCall(2, 'wf-1', 'workspace_file', sub),
toolCall(2, 'wf-1', 'prepare_file_edit', sub),
toolResult(3, 'wf-1', true, undefined, sub),
toolCall(4, 'ec-1', 'edit_content', sub),
toolCall(4, 'ec-1', 'apply_file_edit', sub),
// Section 2 opens before section 1's edit result lands.
toolCall(5, 'wf-2', 'workspace_file', sub),
toolCall(5, 'wf-2', 'prepare_file_edit', sub),
])
// The previous section settles instead of spinning until the turn terminal...
expect(tool(m, 'wf-1').status).toBe('success')
@@ -498,7 +498,7 @@ describe('turn-terminal propagation', () => {
// A file subagent opened but no span end arrived (mid-stream error/disconnect).
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
toolCall(2, 'wf-1', 'workspace_file', { lane: 'subagent', spanId: 'S1' }),
toolCall(2, 'wf-1', 'prepare_file_edit', { lane: 'subagent', spanId: 'S1' }),
])
expect(agent(m, 'S1').endSeq).toBeUndefined()
applyTurnTerminal(m, 'error')
@@ -121,7 +121,7 @@ export interface TurnModel {
>
/**
* Maps a tool call id to another tool node it folds into. Used for the
* `edit_content` -> `workspace_file` row merge so the write streams into the
* `apply_file_edit` -> `prepare_file_edit` row merge so the write streams into the
* single "writing" row rather than a second row.
*/
toolAlias: Map<string, string>
@@ -142,16 +142,16 @@ export function createTurnModel(): TurnModel {
}
}
const WORKSPACE_FILE_TOOL = 'workspace_file'
const EDIT_CONTENT_TOOL = 'edit_content'
const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
const EDIT_CONTENT_TOOL = 'apply_file_edit'
/** Resolves a tool call id through the alias map (e.g. edit_content -> its workspace_file row). */
/** Resolves a tool call id through the alias map (e.g. apply_file_edit -> its prepare_file_edit row). */
export function resolveToolId(model: TurnModel, id: string): string {
return model.toolAlias.get(id) ?? id
}
/**
* Finds the most recent `workspace_file` tool node in a span so an `edit_content`
* Finds the most recent `prepare_file_edit` tool node in a span so an `apply_file_edit`
* write folds into it (the single "writing" row). Co-location in the file
* subagent's span is the link — no coupling to preview phases. The caller
* reopens whatever this returns, including an already-settled row (an edit after
@@ -169,11 +169,11 @@ function findWorkspaceFileNodeInSpan(model: TurnModel, spanId: string): ToolNode
}
/**
* The file agent writes a file as strictly sequential `workspace_file` +
* `edit_content` section pairs, waiting for each to finish before the next. So
* when a new section's `workspace_file` opens, any earlier `workspace_file` row
* The file agent writes a file as strictly sequential `prepare_file_edit` +
* `apply_file_edit` section pairs, waiting for each to finish before the next. So
* when a new section's `prepare_file_edit` opens, any earlier `prepare_file_edit` row
* still `running` in the same span is a completed section whose closing
* `edit_content` result was reordered or dropped — finalize it as success so its
* `apply_file_edit` result was reordered or dropped — finalize it as success so its
* "writing" spinner resolves when the next section starts, instead of lingering
* until the turn-terminal sweep. A no-op on the happy path (prior rows already
* settled on their own result).
@@ -331,8 +331,8 @@ function appendText(
/**
* Applies a result that raced ahead of its tool `call` (buffered under `fromId`)
* onto `node`, then clears the buffer. Used by the normal call path and by the
* edit_content -> workspace_file merge, where the buffer is keyed by the
* edit_content id but folds into the workspace_file row.
* apply_file_edit -> prepare_file_edit merge, where the buffer is keyed by the
* apply_file_edit id but folds into the prepare_file_edit row.
*/
function drainBufferedResult(model: TurnModel, fromId: string, node: ToolNode): void {
const buffered = model.bufferedResults.get(fromId)
@@ -473,12 +473,12 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
ensureSubagentLane(model, spanId, scope, seq, tsMs)
const phase = payload.phase
if (phase === MothershipStreamV1ToolPhase.call) {
// edit_content folds into its span's workspace_file row (the write
// apply_file_edit folds into its span's prepare_file_edit row (the write
// continues in the single "writing" row), reopening it for the edit.
if (toolName === EDIT_CONTENT_TOOL) {
// A re-emitted edit_content call (same tool call id — duplicate/replay)
// A re-emitted apply_file_edit call (same tool call id — duplicate/replay)
// must keep its ORIGINAL target row. Re-running the span lookup can
// return a newer workspace_file, and folding into that would leave the
// return a newer prepare_file_edit, and folding into that would leave the
// first (already reopened) row running with no result ever closing it —
// a spinner stuck until the turn-terminal sweep. So once aliased, reuse.
const aliasedId = model.toolAlias.get(rawToolCallId)
@@ -492,7 +492,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
parent.status = 'running'
parent.result = undefined
// A result that raced ahead of this call was buffered under the
// edit_content id; fold it into the reopened workspace_file row.
// apply_file_edit id; fold it into the reopened prepare_file_edit row.
drainBufferedResult(model, rawToolCallId, parent)
break
}
@@ -1,7 +1,7 @@
import type { ChatContext } from '@/stores/panel'
import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types'
const EDIT_CONTENT_TOOL_ID = 'edit_content'
const EDIT_CONTENT_TOOL_ID = 'apply_file_edit'
const RUN_SUBAGENT_ID = 'run'
export type {
@@ -191,7 +191,10 @@ export const SUBAGENT_LABELS: Record<string, string> = {
search: 'Search Agent',
superagent: 'Superagent',
run: 'Run Agent',
agent: 'Tools Agent',
// The extensions subagent's wire/scope AgentID stays `agent` (pre-rename);
// `extensions` is its current model-facing trigger tool name.
agent: 'Extensions Agent',
extensions: 'Extensions Agent',
// `job` retained as a backward-compat alias so historical transcripts still render a label.
job: 'Job Agent',
file: 'File Agent',
@@ -115,6 +115,13 @@ interface TableProps {
* context to resolve it — stays on today's Filter/Sort bar.
*/
viewsEnabled?: boolean
/**
* Saved view to adopt on first seed instead of the table's default —
* embedded mode only, set when the agent opened this table pinned to a
* view. Participates only in the one-time adoption branch, so it never
* fights a later user switch.
*/
initialViewId?: string
}
/**
@@ -213,6 +220,7 @@ function isSameViewConfig(a: TableViewConfig, b: TableViewConfig): boolean {
*/
export function Table({
embedded,
initialViewId,
workspaceId: propWorkspaceId,
tableId: propTableId,
tableLocksEnabled = false,
@@ -546,7 +554,9 @@ export function Table({
!views.some((view) => view.id === activeViewId)
if (activeViewId === null || inheritedParams) {
const defaultView = views.find((view) => view.isDefault)
const pinnedView =
embedded && initialViewId ? views.find((view) => view.id === initialViewId) : undefined
const defaultView = pinnedView ?? views.find((view) => view.isDefault)
// `sort` rides the same host URL, so when the view id is inherited the
// sort beside it is too — not local work, and it must not suppress the
// default view's own sort.
+1 -1
View File
@@ -83,7 +83,7 @@ export const listCustomBlocksQuerySchema = z.object({
* Icon URLs are rendered as org-wide `<img>` sources, so only https URLs and
* internal file-serve paths (what the icon upload UI stores) are accepted —
* never data:/blob:/other schemes an admin could smuggle into shared metadata.
* Shared with the copilot deploy_custom_block handler's pass-through branch.
* Shared with the copilot publish_custom_block handler's pass-through branch.
*/
export function isAllowedCustomBlockIconUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('/api/files/serve/')
+1 -1
View File
@@ -702,7 +702,7 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise<b
* Existing Function execution deliberately does not consult it (see
* `resolveWorkspaceSandbox`), so a workspace that downgrades keeps running the
* sandboxes it already built. New Copilot discovery, mutations, attachments,
* and direct function_execute selections do re-check it.
* and direct run_function selections do re-check it.
*/
export async function hasWorkspaceSandboxAccess(workspaceId: string): Promise<boolean> {
try {
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
createFromFile: { operation: { id: 'tables.imports.create_from_workspace_file' } },
createWorkflowGroup: { operation: { id: 'tables.groups.create' } },
deleteTables: { operation: { id: 'tables.delete' } },
importFile: { operation: { id: 'tables.imports.workspace_file' } },
importFile: { operation: { id: 'tables.imports.prepare_file_edit' } },
replaceProjectedRows: { operation: { id: 'tables.rows.replace' } },
updateWorkflowGroup: { operation: { id: 'tables.groups.update' } },
},
@@ -282,7 +282,7 @@ describe('async tool repository single-row semantics', () => {
const existingRow = {
runId: 'run-1',
toolCallId: 'tool-1',
toolName: 'function_execute',
toolName: 'run_function',
args: { language: 'javascript', code: 'return {{FIRST_SECRET}}' },
status,
}
@@ -291,7 +291,7 @@ describe('async tool repository single-row semantics', () => {
const result = await upsertAsyncToolCall({
runId: 'run-1',
toolCallId: 'tool-1',
toolName: 'function_execute',
toolName: 'run_function',
args: { language: 'javascript', code: 'return {{SECOND_SECRET}}' },
status: 'pending',
})
+5 -5
View File
@@ -138,17 +138,17 @@ function toDisplayContexts(
}))
}
const WORKSPACE_FILE_TOOL = 'workspace_file'
const EDIT_CONTENT_TOOL = 'edit_content'
const WORKSPACE_FILE_TOOL = 'prepare_file_edit'
const EDIT_CONTENT_TOOL = 'apply_file_edit'
const MAIN_SPAN = 'main'
/**
* Collapses an `edit_content` write into the most-recent `workspace_file` row in
* Collapses an `apply_file_edit` write into the most-recent `prepare_file_edit` row in
* the same subagent span, mirroring the live turn-model fold. The live view
* folds these in `reduceEvent`, but the persisted transcript stores them as two
* separate tool blocks; without this a reloaded chat splits the file write into
* "workspace_file" + "edit_content" rows (and a refresh mid-write leaves the
* second row spinning). The reopened row inherits the edit_content's final
* "prepare_file_edit" + "apply_file_edit" rows (and a refresh mid-write leaves the
* second row spinning). The reopened row inherits the apply_file_edit's final
* status/result, exactly as the live single "writing" row resolves. Every other
* block is passed through untouched, so this only affects file writes.
*/
@@ -242,7 +242,7 @@ describe('buildEffectiveChatTranscript', () => {
payload: {
phase: 'result',
toolCallId: 'tool-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
executor: 'go',
mode: 'sync',
success: false,
@@ -262,7 +262,7 @@ describe('buildEffectiveChatTranscript', () => {
type: MothershipStreamV1EventType.tool,
toolCall: expect.objectContaining({
id: 'tool-1',
name: 'workspace_file',
name: 'prepare_file_edit',
state: MothershipStreamV1CompletionStatus.cancelled,
}),
}),
+2 -2
View File
@@ -368,7 +368,7 @@ describe('buildCopilotRequestPayload', () => {
content: [
'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded.',
'Read with: read("uploads/payroll.xlsx")',
'To save permanently: materialize_file(fileName: "payroll.xlsx")',
'To save permanently: save_upload(fileName: "payroll.xlsx")',
].join('\n'),
},
])
@@ -410,7 +410,7 @@ describe('buildCopilotRequestPayload', () => {
content: [
'File "photo.png" (image/png, 10 bytes) uploaded.',
'Read with: read("uploads/photo.png")',
'To save permanently: materialize_file(fileName: "photo.png")',
'To save permanently: save_upload(fileName: "photo.png")',
].join('\n'),
},
])
+3 -3
View File
@@ -362,7 +362,7 @@ export async function buildCopilotRequestPayload(
userMessageId
)
// Encode the read path per the percent-encoded VFS convention (matches
// files/ and the uploads glob output). The materialize_file `fileName`
// files/ and the uploads glob output). The save_upload `fileName`
// arg stays the raw display name — the upload resolver accepts both.
let encodedUploadName = displayName
try {
@@ -382,11 +382,11 @@ export async function buildCopilotRequestPayload(
lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
`To save permanently: save_upload(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
`To import as a workflow: save_upload(fileName: "${displayName}", operation: "import")`
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
*/
import { describe, expect, it } from 'vitest'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import { buildToolCallSummaries } from '@/lib/copilot/request/context/result'
import { TraceCollector } from '@/lib/copilot/request/trace'
import type { StreamingContext } from '@/lib/copilot/request/types'
@@ -44,7 +44,7 @@ describe('buildToolCallSummaries', () => {
const context = makeContext()
context.toolCalls.set('tool-1', {
id: 'tool-1',
name: 'download_to_workspace_file',
name: 'download_file',
status: 'pending',
startTime: 1,
})
@@ -59,7 +59,7 @@ describe('buildToolCallSummaries', () => {
const context = makeContext()
context.toolCalls.set('tool-2', {
id: 'tool-2',
name: FunctionExecute.id,
name: RunFunction.id,
status: 'executing',
startTime: 1,
})
@@ -76,7 +76,7 @@ describe('buildToolCallSummaries', () => {
const context = makeContext()
context.toolCalls.set('tool-3', {
id: 'tool-3',
name: 'download_to_workspace_file',
name: 'download_file',
status: MothershipStreamV1ToolOutcome.cancelled,
result: { success: false },
error: 'Stopped by user',
@@ -89,7 +89,7 @@ describe('buildToolCallSummaries', () => {
expect(summaries).toHaveLength(1)
expect(summaries[0]).toEqual({
id: 'tool-3',
name: 'download_to_workspace_file',
name: 'download_file',
status: MothershipStreamV1ToolOutcome.cancelled,
params: undefined,
result: undefined,
@@ -54,21 +54,21 @@ function toolEvent(payload: Record<string, unknown>): StreamEvent {
)
}
/** One args_delta chunk of the streamed `edit_content` JSON, as a driveable StreamEvent. */
/** One args_delta chunk of the streamed `apply_file_edit` JSON, as a driveable StreamEvent. */
function editContentDelta(argumentsDelta: string): StreamEvent {
return toolEvent({
toolCallId: EDIT_TOOL_CALL_ID,
toolName: 'edit_content',
toolName: 'apply_file_edit',
phase: MothershipStreamV1ToolPhase.args_delta,
argumentsDelta,
})
}
/** The authoritative `workspace_file` call frame for a path-targeted update. */
/** The authoritative `prepare_file_edit` call frame for a path-targeted update. */
function workspaceFileCall(): StreamEvent {
return toolEvent({
toolCallId: WORKSPACE_FILE_TOOL_CALL_ID,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
phase: MothershipStreamV1ToolPhase.call,
arguments: {
operation: 'update',
@@ -382,7 +382,7 @@ export async function processFilePreviewStreamEvent(input: {
// Scope the in-flight intent to the invoking file subagent's channel (its
// outer tool_use id) so two file agents streaming concurrently never read or
// overwrite each other's intent. workspace_file and edit_content from the same
// overwrite each other's intent. prepare_file_edit and apply_file_edit from the same
// file agent share this channel id, so they pair up; siblings stay isolated.
const channelId = streamEvent.scope?.parentToolCallId ?? ''
const getIntent = (): FileIntent | null => context.activeFileIntents.get(channelId) ?? null
@@ -393,7 +393,7 @@ export async function processFilePreviewStreamEvent(input: {
context.activeFileIntents.delete(channelId)
}
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file') {
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'prepare_file_edit') {
const toolCallId = streamEvent.payload.toolCallId
const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments)
if (toolCallId && parsedArgs) {
@@ -408,7 +408,7 @@ export async function processFilePreviewStreamEvent(input: {
const { fileId, fileName } = target
const isContentOp = isContentOperation(operation)
// Per-channel: a re-declared workspace_file just overwrites THIS channel's
// Per-channel: a re-declared prepare_file_edit just overwrites THIS channel's
// slot. No cross-message intent clearing — that would wipe a concurrent
// sibling file agent's pending intent.
const intent: FileIntent = {
@@ -451,12 +451,12 @@ export async function processFilePreviewStreamEvent(input: {
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_start',
})
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_target',
operation,
target: {
@@ -469,7 +469,7 @@ export async function processFilePreviewStreamEvent(input: {
if (edit) {
await emitPreviewEvent(streamEvent, options, {
toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_edit_meta',
edit,
})
@@ -481,7 +481,7 @@ export async function processFilePreviewStreamEvent(input: {
const workspaceResultIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'workspace_file' &&
streamEvent.payload.toolName === 'prepare_file_edit' &&
workspaceResultIntent &&
isContentOperation(workspaceResultIntent.operation)
) {
@@ -526,12 +526,12 @@ export async function processFilePreviewStreamEvent(input: {
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_start',
})
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_target',
operation: intent.operation,
target: {
@@ -544,7 +544,7 @@ export async function processFilePreviewStreamEvent(input: {
if (intent.edit) {
await emitPreviewEvent(streamEvent, options, {
toolCallId: intent.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_edit_meta',
edit: intent.edit,
})
@@ -555,7 +555,7 @@ export async function processFilePreviewStreamEvent(input: {
const patchDeleteIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'workspace_file' &&
streamEvent.payload.toolName === 'prepare_file_edit' &&
patchDeleteIntent &&
isContentOperation(patchDeleteIntent.operation) &&
patchDeleteIntent.operation === 'patch' &&
@@ -594,7 +594,7 @@ export async function processFilePreviewStreamEvent(input: {
await persistFilePreviewSession(nextSession)
await emitPreviewEvent(streamEvent, options, {
toolCallId: nextSession.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_content',
content: previewText,
contentMode: 'snapshot',
@@ -608,7 +608,10 @@ export async function processFilePreviewStreamEvent(input: {
}
}
if (isToolArgsDeltaStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') {
if (
isToolArgsDeltaStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'apply_file_edit'
) {
const toolCallId = streamEvent.payload.toolCallId
const delta = streamEvent.payload.argumentsDelta
const stateForTool = editContentState.get(toolCallId) ?? { raw: '' }
@@ -685,7 +688,7 @@ export async function processFilePreviewStreamEvent(input: {
// collaborative editor for this file is open, that client applies the stream to the shared
// doc as minimal CRDT diffs (see `applyStreamedMarkdownToLiveDoc` in the editor), which
// renders smoothly locally AND broadcasts to every peer — so a server-side streaming merge
// would double-write the shared doc. The final `edit_content` durable write still reconciles
// would double-write the shared doc. The final `apply_file_edit` durable write still reconciles
// the file and seeds any late joiner.
if (
@@ -714,7 +717,7 @@ export async function processFilePreviewStreamEvent(input: {
await emitPreviewEvent(streamEvent, options, {
toolCallId: nextSession.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_content',
content: previewUpdate.content,
contentMode: previewUpdate.contentMode,
@@ -739,7 +742,7 @@ export async function processFilePreviewStreamEvent(input: {
editContentState.set(toolCallId, stateForTool)
}
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'edit_content') {
if (isToolCallStreamEvent(streamEvent) && streamEvent.payload.toolName === 'apply_file_edit') {
const toolCallId = streamEvent.payload.toolCallId
if (toolCallId) {
editContentState.delete(toolCallId)
@@ -749,7 +752,7 @@ export async function processFilePreviewStreamEvent(input: {
const editResultIntent = getIntent()
if (
isToolResultStreamEvent(streamEvent) &&
streamEvent.payload.toolName === 'edit_content' &&
streamEvent.payload.toolName === 'apply_file_edit' &&
editResultIntent
) {
const currentPreview = filePreviewState.get(editResultIntent.toolCallId)
@@ -767,7 +770,7 @@ export async function processFilePreviewStreamEvent(input: {
})
await emitPreviewEvent(streamEvent, options, {
toolCallId: currentPreview.session.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_content',
content: currentPreview.session.previewText,
contentMode: 'snapshot',
@@ -801,7 +804,7 @@ export async function processFilePreviewStreamEvent(input: {
await emitPreviewEvent(streamEvent, options, {
toolCallId: editResultIntent.toolCallId,
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_complete',
fileId: editResultIntent.target.fileId,
output: streamEvent.payload.output,
+11 -11
View File
@@ -181,7 +181,7 @@ describe('copilot go stream helpers', () => {
expect(decodeJsonStringPrefix('partial \\u26')).toBe('partial ')
})
it('extracts the streamed edit_content prefix from partial JSON', () => {
it('extracts the streamed apply_file_edit prefix from partial JSON', () => {
expect(extractEditContent('{"content":"hello\\nwor')).toBe('hello\nwor')
expect(extractEditContent('{"content":"tab\\tvalue"}')).toBe('tab\tvalue')
})
@@ -216,7 +216,7 @@ describe('copilot go stream helpers', () => {
})
})
it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => {
it('hydrates path-based prepare_file_edit edits into file preview events before apply_file_edit streams', async () => {
listAllWorkspaceFilesMock.mockResolvedValue({
files: [{ id: 'file-1', name: 'notes.md', folderPath: null }],
})
@@ -229,7 +229,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-path-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
@@ -248,7 +248,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-path-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
@@ -267,7 +267,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-path-1',
toolName: 'edit_content',
toolName: 'apply_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.args_delta,
@@ -282,7 +282,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-path-1',
toolName: 'edit_content',
toolName: 'apply_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
@@ -378,7 +378,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'workspace-file-alias-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.call,
@@ -397,7 +397,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-alias-1',
toolName: 'edit_content',
toolName: 'apply_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.args_delta,
@@ -412,7 +412,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'edit-content-alias-1',
toolName: 'edit_content',
toolName: 'apply_file_edit',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
@@ -492,7 +492,7 @@ describe('copilot go stream helpers', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-result-dedupe',
toolName: 'search_online',
toolName: 'web_search',
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
phase: MothershipStreamV1ToolPhase.result,
@@ -541,7 +541,7 @@ describe('copilot go stream helpers', () => {
expect(context.toolCalls.get('tool-result-dedupe')).toEqual(
expect.objectContaining({
id: 'tool-result-dedupe',
name: 'search_online',
name: 'web_search',
status: MothershipStreamV1ToolOutcome.success,
result: { success: true, output: { value: 'ok' } },
})
@@ -87,7 +87,7 @@ import {
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import {
prePersistClientExecutableToolCall,
sseHandlers,
@@ -244,7 +244,7 @@ describe('sse-handlers tool lifecycle', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'deploy-1',
toolName: 'deploy_api',
toolName: 'deploy_as_api',
arguments: { versionName: 'v2' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
@@ -259,7 +259,7 @@ describe('sse-handlers tool lifecycle', () => {
expect(upsertAsyncToolCall).toHaveBeenCalledWith({
runId: 'run-1',
toolCallId: 'deploy-1',
toolName: 'deploy_api',
toolName: 'deploy_as_api',
args: { versionName: 'v2' },
status: MothershipStreamV1AsyncToolRecordStatus.pending,
})
@@ -330,14 +330,14 @@ describe('sse-handlers tool lifecycle', () => {
context.runId = 'run-1'
context.toolPermissions = {
enabled: true,
autoAllowed: new Set(['deploy_api']),
autoAllowed: new Set(['deploy_as_api']),
}
const event = {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'deploy-2',
toolName: 'deploy_api',
toolName: 'deploy_as_api',
arguments: {},
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
@@ -562,7 +562,7 @@ describe('sse-handlers tool lifecycle', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'tool-function',
toolName: FunctionExecute.id,
toolName: RunFunction.id,
arguments: { code: 'return {{SECRET}}' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
@@ -1097,7 +1097,7 @@ describe('sse-handlers tool lifecycle', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'function-finalized-args',
toolName: FunctionExecute.id,
toolName: RunFunction.id,
arguments: { language: 'javascript', code: 'return {{STALE_SECRET}}' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
@@ -1115,7 +1115,7 @@ describe('sse-handlers tool lifecycle', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'function-finalized-args',
toolName: FunctionExecute.id,
toolName: RunFunction.id,
arguments: { language: 'javascript', code: 'return 1' },
executor: MothershipStreamV1ToolExecutor.sim,
mode: MothershipStreamV1ToolMode.async,
@@ -1130,7 +1130,7 @@ describe('sse-handlers tool lifecycle', () => {
await sleep(0)
expect(executeTool).toHaveBeenCalledWith(
FunctionExecute.id,
RunFunction.id,
{ language: 'javascript', code: 'return 1' },
expect.any(Object)
)
@@ -501,8 +501,8 @@ async function handleCallPhase(
if (!toolCall) return
// Capture the invoking subagent's channel id so the executor can thread it
// into the server tool context — this is what scopes the workspace_file ->
// edit_content intent handoff to one file subagent under concurrency.
// into the server tool context — this is what scopes the prepare_file_edit ->
// apply_file_edit intent handoff to one file subagent under concurrency.
if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId
const readPath = typeof args?.path === 'string' ? args.path : undefined
@@ -156,7 +156,7 @@ describe('stream session contract parser', () => {
type: 'tool' as const,
payload: {
toolCallId: 'preview-1',
toolName: 'workspace_file' as const,
toolName: 'prepare_file_edit' as const,
previewPhase: 'file_preview_content' as const,
content: 'draft body',
contentMode: 'snapshot' as const,
@@ -47,7 +47,7 @@ export interface SyntheticFilePreviewTarget {
export interface SyntheticFilePreviewStartPayload {
previewPhase: typeof FILE_PREVIEW_PHASE.start
toolCallId: string
toolName: 'workspace_file'
toolName: 'prepare_file_edit'
}
export interface SyntheticFilePreviewTargetPayload {
@@ -56,14 +56,14 @@ export interface SyntheticFilePreviewTargetPayload {
target: SyntheticFilePreviewTarget
title?: string
toolCallId: string
toolName: 'workspace_file'
toolName: 'prepare_file_edit'
}
export interface SyntheticFilePreviewEditMetaPayload {
edit: JsonRecord
previewPhase: typeof FILE_PREVIEW_PHASE.editMeta
toolCallId: string
toolName: 'workspace_file'
toolName: 'prepare_file_edit'
}
export interface SyntheticFilePreviewContentPayload {
@@ -77,7 +77,7 @@ export interface SyntheticFilePreviewContentPayload {
previewVersion: number
targetKind?: string
toolCallId: string
toolName: 'workspace_file'
toolName: 'prepare_file_edit'
}
export interface SyntheticFilePreviewCompletePayload {
@@ -86,7 +86,7 @@ export interface SyntheticFilePreviewCompletePayload {
previewPhase: typeof FILE_PREVIEW_PHASE.complete
previewVersion?: number
toolCallId: string
toolName: 'workspace_file'
toolName: 'prepare_file_edit'
}
export type SyntheticFilePreviewPayload =
@@ -360,7 +360,7 @@ function isSyntheticFilePreviewPayload(value: unknown): value is SyntheticFilePr
return false
}
if (typeof value.toolCallId !== 'string' || value.toolName !== 'workspace_file') {
if (typeof value.toolCallId !== 'string' || value.toolName !== 'prepare_file_edit') {
return false
}
@@ -43,7 +43,7 @@ describe('createEvent', () => {
payload: {
previewPhase: 'file_preview_start',
toolCallId: 'preview-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
},
})
@@ -56,7 +56,7 @@ describe('createEvent', () => {
payload: {
previewPhase: 'file_preview_start',
toolCallId: 'preview-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
},
})
})
@@ -168,7 +168,7 @@ describe('StreamWriter', () => {
type: MothershipStreamV1EventType.tool,
payload: {
toolCallId: 'preview-1',
toolName: 'workspace_file',
toolName: 'prepare_file_edit',
previewPhase: 'file_preview_start',
},
} satisfies StreamEvent)
@@ -36,7 +36,7 @@ describe('shouldSkipToolCallEvent', () => {
it('keeps non-vfs generating placeholders visible', () => {
expect(
shouldSkipToolCallEvent(
toolCallEvent('search-generating-placeholder', 'search_online', undefined, true)
toolCallEvent('search-generating-placeholder', 'web_search', undefined, true)
)
).toBe(false)
})
@@ -115,7 +115,7 @@ describe('toolWatchdogTimeoutMs', () => {
expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS)
})
it.each(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy', 'promote_to_live'])(
it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp', 'redeploy', 'promote_to_live'])(
'does not undercut deployment tool %s with the default watchdog',
(toolName) => {
expect(toolWatchdogTimeoutMs(toolName)).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS)
+24 -24
View File
@@ -20,36 +20,36 @@ import {
MothershipStreamV1ToolPhase,
} from '@/lib/copilot/generated/mothership-stream-v1'
import {
ApplyFileEdit,
BrowserRequestTakeover,
CrawlWebsite,
CreateFile,
CreateEmptyFile,
CreateWorkflow,
DeployApi,
DeployChat,
DeployCustomBlock,
DeployMcp,
DownloadToWorkspaceFile,
EditContent,
DeployAsApi,
DeployAsChat,
DeployAsMcp,
DownloadFile,
Ffmpeg,
FunctionExecute,
GenerateApiKey,
GenerateAudio,
GenerateImage,
GenerateVideo,
KnowledgeBase,
LoadDeployment,
MaterializeFile,
ManageKnowledgeBase,
Media,
PrepareFileEdit,
PromoteToLive,
PublishCustomBlock,
Redeploy,
Run,
RunBlock,
RunCode,
RunFromBlock,
RunFunction,
RunWorkflow,
RunWorkflowUntilBlock,
SaveUpload,
Search,
WorkspaceFile,
WebCrawl,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
@@ -217,7 +217,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
RunFromBlock.id,
RunWorkflow.id,
RunWorkflowUntilBlock.id,
FunctionExecute.id,
RunFunction.id,
RunCode.id,
GenerateImage.id,
GenerateAudio.id,
@@ -225,17 +225,17 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
Ffmpeg.id,
Media.id,
Search.id,
CrawlWebsite.id,
KnowledgeBase.id,
DownloadToWorkspaceFile.id,
CreateFile.id,
EditContent.id,
MaterializeFile.id,
WorkspaceFile.id,
DeployApi.id,
DeployChat.id,
DeployCustomBlock.id,
DeployMcp.id,
WebCrawl.id,
ManageKnowledgeBase.id,
DownloadFile.id,
CreateEmptyFile.id,
ApplyFileEdit.id,
SaveUpload.id,
PrepareFileEdit.id,
DeployAsApi.id,
DeployAsChat.id,
PublishCustomBlock.id,
DeployAsMcp.id,
Redeploy.id,
LoadDeployment.id,
PromoteToLive.id,
@@ -24,7 +24,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({
) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }),
}))
import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1'
import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import {
extractTabularData,
maybeWriteOutputToFile,
@@ -37,7 +37,7 @@ import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limit
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
describe('unwrapFunctionExecuteOutput', () => {
it('unwraps the function_execute envelope { result, stdout }', () => {
it('unwraps the run_function envelope { result, stdout }', () => {
expect(unwrapFunctionExecuteOutput({ result: 'name,age\nAlice,30', stdout: '' })).toBe(
'name,age\nAlice,30'
)
@@ -56,7 +56,7 @@ describe('unwrapFunctionExecuteOutput', () => {
})
describe('serializeOutputForFile (csv)', () => {
it('returns raw CSV text when function_execute result is already a CSV string', () => {
it('returns raw CSV text when run_function result is already a CSV string', () => {
const output = {
result: 'name,age\nAlice,30\nBob,40',
stdout: '(2 rows)',
@@ -139,7 +139,7 @@ describe('maybeWriteOutputToFile', () => {
it('denies a read-only principal without writing the file', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext({ userPermission: 'read' })
@@ -152,7 +152,7 @@ describe('maybeWriteOutputToFile', () => {
it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } },
buildContext({ userPermission: 'read' })
@@ -164,7 +164,7 @@ describe('maybeWriteOutputToFile', () => {
it('writes the output file for a write principal', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext()
@@ -201,7 +201,7 @@ describe('maybeWriteOutputToFile', () => {
}))
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } },
{ success: true, output: { result: rows, stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -239,7 +239,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('TOKEN', 'secret-value')
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
{
success: true,
@@ -280,7 +280,7 @@ describe('maybeWriteOutputToFile', () => {
}
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } },
{ success: true, output: runtimeOutput },
buildContext({ resolvedSecretTraceRegistry: toolRegistry })
@@ -317,7 +317,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('CSV_SECRET', secret)
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: [{ value: secret }], stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -356,7 +356,7 @@ describe('maybeWriteOutputToFile', () => {
const rows = Array.from({ length: 10_000 }, () => ({ first: secret, second: secret }))
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: rows, stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -377,7 +377,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('CSV_SECRET', secret)
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{
outputs: {
files: [
@@ -402,7 +402,7 @@ describe('maybeWriteOutputToFile', () => {
}))
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files } },
{ success: true, output: { result: 'content', stdout: '' } },
buildContext()
@@ -420,7 +420,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('API_KEY', 'secret-value')
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
{ success: true, output: { result: '__var_API_KEY', stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -457,7 +457,7 @@ describe('maybeWriteOutputToFile', () => {
} as unknown as ResolvedSecretTraceRegistry
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
{ success: true, output: { result: 'anonymous-secret', stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -495,7 +495,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
{ success: true, output: { result: 'secret-value', stdout: '' } },
buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry })
@@ -534,7 +534,7 @@ describe('maybeWriteOutputToFile', () => {
registry.recordResolved('OUTPUT_SECRET', 'secret-value')
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.txt', mode: 'overwrite' }] } },
{ success: true, output: { result: 'secret-value', stdout: '' } },
buildContext({ userId: 'billing-actor', resolvedSecretTraceRegistry: registry })
@@ -557,7 +557,7 @@ describe('maybeWriteOutputToFile', () => {
} as unknown as ResolvedSecretTraceRegistry
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{
outputs: {
files: [
@@ -580,7 +580,7 @@ describe('maybeWriteOutputToFile', () => {
it('preserves legacy writes without a registry and marks their provenance unknown', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.json', mode: 'overwrite' }] } },
{ success: true, output: { result: { token: 'unknown' }, stdout: '' } },
buildContext({ resolvedSecretTraceRegistry: undefined })
@@ -595,7 +595,7 @@ describe('maybeWriteOutputToFile', () => {
it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => {
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{ outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } },
{ success: true, output: { result: 'name,age\nAlice,30', stdout: '' } },
buildContext({ workspaceId: undefined })
@@ -611,7 +611,7 @@ describe('maybeWriteOutputToFile', () => {
it('still passes results through untouched when no outputs are declared, even without workspace context', async () => {
const original = { success: true, output: { result: 42, stdout: '' } }
const result = await maybeWriteOutputToFile(
FunctionExecute.id,
RunFunction.id,
{},
original,
buildContext({ workspaceId: undefined })
@@ -627,7 +627,7 @@ describe('extractTabularData', () => {
expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }])
})
it('does NOT unwrap function_execute envelopes on its own (callers must pre-unwrap)', () => {
it('does NOT unwrap run_function envelopes on its own (callers must pre-unwrap)', () => {
// Caller is responsible for unwrapping { result, stdout } envelopes first.
// Keeping that concern out of this function prevents a double unwrap when
// the user's payload itself happens to have matching keys.
+5 -5
View File
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
import { RunFunction, UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
@@ -26,7 +26,7 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr
const logger = createLogger('CopilotToolResultFiles')
const MAX_OUTPUT_FILE_PROVENANCE_REPRESENTATIONS = 10_000
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([FunctionExecute.id, UserTable.id])
export const OUTPUT_PATH_TOOLS: Set<string> = new Set([RunFunction.id, UserTable.id])
export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html'
@@ -47,12 +47,12 @@ export const FORMAT_TO_CONTENT_TYPE: Record<OutputFormat, string> = {
}
/**
* Unwraps the `function_execute` response envelope `{ result, stdout }` so the
* Unwraps the `run_function` response envelope `{ result, stdout }` so the
* rest of the serialization code works on the user's actual payload (a string,
* array, object, etc.) instead of JSON-stringifying the envelope itself.
*
* Only unwraps when both keys are present — that's the unique shape of
* `function_execute` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`).
* `run_function` (see `apps/sim/tools/function/types.ts` `CodeExecutionOutput`).
* `user_table` returns `{ data, message, success }` which is left alone.
*/
export function unwrapFunctionExecuteOutput(output: unknown): unknown {
@@ -66,7 +66,7 @@ export function unwrapFunctionExecuteOutput(output: unknown): unknown {
/**
* Try to pull a flat array of row-objects out of an already-unwrapped tool
* payload. Callers are responsible for stripping any `function_execute`
* payload. Callers are responsible for stripping any `run_function`
* envelope first (via {@link unwrapFunctionExecuteOutput}) — this function
* does not re-unwrap, so a user payload that coincidentally has `result` and
* `stdout` keys is not mistaken for another envelope.
@@ -94,7 +94,7 @@ describe('toolCallNeedsApproval', () => {
expect(toolCallNeedsApproval('terminal', context, {}, false, runCall)).toBe(false)
})
it.each(['deploy_api', 'deploy_chat', 'deploy_mcp'])(
it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp'])(
'honors the saved permission for a %s undeploy',
(toolName) => {
const context = makeContext()
@@ -108,10 +108,10 @@ describe('toolCallNeedsApproval', () => {
it('applies the normal saved permission to code with a secret reference', () => {
const context = makeContext()
context.toolPermissions.autoAllowed.add('function_execute')
context.toolPermissions.autoAllowed.add('run_function')
expect(
toolCallNeedsApproval('function_execute', context, {}, false, {
toolCallNeedsApproval('run_function', context, {}, false, {
language: 'javascript',
code: 'return {{API_KEY}}',
})
@@ -135,7 +135,7 @@ describe('toolCallNeedsApproval', () => {
context.toolPermissions.enabled = false
expect(
toolCallNeedsApproval('function_execute', context, {}, false, {
toolCallNeedsApproval('run_function', context, {}, false, {
language: 'javascript',
code: 'return {{API_KEY}}',
})
@@ -214,13 +214,13 @@ describe('gated tools are askable', () => {
).toEqual([
'call_integration_tool',
'delete_workspace_mcp_server',
'deploy_api',
'deploy_chat',
'deploy_mcp',
'function_execute',
'deploy_as_api',
'deploy_as_chat',
'deploy_as_mcp',
'promote_to_live',
'redeploy',
'run_code',
'run_function',
'run_workflow',
'run_workflow_until_block',
'terminal',
@@ -323,7 +323,7 @@ describe('runGatedToolExecution', () => {
it('accepts the normal chat-level decision for code with a secret reference', async () => {
const context = makeContext()
const toolCall = makeToolCall()
toolCall.name = 'function_execute'
toolCall.name = 'run_function'
toolCall.params = { language: 'javascript', code: 'return {{API_KEY}}' }
const execute = vi.fn().mockResolvedValue({ status: 'success' })
waitForToolPermissionDecision.mockResolvedValue({
@@ -334,7 +334,7 @@ describe('runGatedToolExecution', () => {
await gate(context, toolCall, execute, [])
expect(execute).toHaveBeenCalledTimes(1)
expect(context.toolPermissions.autoAllowed.has('function_execute')).toBe(true)
expect(context.toolPermissions.autoAllowed.has('run_function')).toBe(true)
})
it('does not suppress later prompts for a one-off allow', async () => {
@@ -4,7 +4,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ
/**
* Guards a post-tool output-redirection sink against read-only principals.
*
* `function_execute`, `user_table`, and `read` are read-allowed for execution
* `run_function`, `user_table`, and `read` are read-allowed for execution
* (they don't mutate the workspace themselves), so the router's `WRITE_ACTIONS`
* gate in `tools/server/router.ts` lets read-only collaborators run them. But
* their output-redirection declarations (`outputs.files`, `outputTable`)
@@ -12,7 +12,7 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/typ
* Those writes must satisfy the same write gate as the dedicated mutation tools.
*
* Returns a denial `ToolCallResult` when the caller lacks write access (so the
* agent surfaces the same `Permission denied` outcome it gets from `create_file`
* agent surfaces the same `Permission denied` outcome it gets from `create_empty_file`
* / `user_table` writes), or `null` when the write may proceed.
*/
export function denyOutputWriteWithoutWritePermission(
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { FunctionExecute, RunCode } from '@/lib/copilot/generated/tool-catalog-v1'
import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import {
projectToolResultForCopilot,
TOOL_RESULT_UNAVAILABLE_ERROR,
@@ -20,7 +20,7 @@ function createRegistry(): ResolvedSecretTraceRegistry {
}
describe('projectToolResultForCopilot', () => {
it.each([FunctionExecute.id, RunCode.id])(
it.each([RunFunction.id, RunCode.id])(
'projects active exact and embedded secrets for %s without mutating runtime output',
(toolName) => {
const registry = createRegistry()
@@ -30,7 +30,7 @@ vi.mock('@/lib/table/application/rows', () => ({
ProjectedWireRowsValidationError: class ProjectedWireRowsValidationError extends Error {},
}))
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import {
maybeWriteOutputToTable,
@@ -94,7 +94,7 @@ describe('automatic Copilot tool-output table persistence', () => {
]
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: rows } },
context
@@ -129,7 +129,7 @@ describe('automatic Copilot tool-output table persistence', () => {
const runtimeRows = [{ name: 'secret-value', status: 'literal' }]
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: runtimeRows } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -159,7 +159,7 @@ describe('automatic Copilot tool-output table persistence', () => {
registry.markIncomplete('unspecified')
await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: [{ name: 'unknown' }] } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -179,7 +179,7 @@ describe('automatic Copilot tool-output table persistence', () => {
)
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: [{ wrong: true }] } },
buildContext()
@@ -199,7 +199,7 @@ describe('automatic Copilot tool-output table persistence', () => {
mocks.executeReplace.mockRejectedValueOnce(new Error('database duplicate: secret-value'))
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: [{ name: 'secret-value' }] } },
buildContext({ resolvedSecretTraceRegistry: registry })
@@ -217,7 +217,7 @@ describe('automatic Copilot tool-output table persistence', () => {
it('rejects read-only Copilot execution before any application command', async () => {
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: [{ name: 'Ada' }] } },
buildContext({ userPermission: 'read' })
@@ -232,7 +232,7 @@ describe('automatic Copilot tool-output table persistence', () => {
mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 })
const result = await maybeWriteOutputToTable(
FunctionExecute.id,
RunFunction.id,
{ outputTable: 'table-1' },
{ success: true, output: { result: [{ name: 'Ada' }, { name: 'Grace' }] } },
buildContext()
+2 -2
View File
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { parse as csvParse } from 'csv-parse/sync'
import { executeCopilotReplaceProjectedWireRows } from '@/lib/copilot/application/table-commands'
import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation'
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
@@ -68,7 +68,7 @@ export async function maybeWriteOutputToTable(
result: ToolCallResult,
context: ExecutionContext
): Promise<ToolCallResult> {
if (toolName !== FunctionExecute.id) return result
if (toolName !== RunFunction.id) return result
if (!result.success || !result.output) return result
const outputTable = params?.outputTable as string | undefined
if (!outputTable) return result
+1 -1
View File
@@ -41,7 +41,7 @@ export interface ToolCallState {
* For a subagent-scoped tool call, the invoking subagent's channel id (its
* outer tool_use id, = event.scope.parentToolCallId). Captured at dispatch so
* the executor can thread it into the server tool context and scope the
* workspace_file -> edit_content intent handoff per file subagent. Undefined
* prepare_file_edit -> apply_file_edit intent handoff per file subagent. Undefined
* for main-lane tool calls.
*/
parentToolCallId?: string
@@ -5,9 +5,9 @@ import { describe, expect, it } from 'vitest'
import { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult } from './extraction'
describe('extractResourcesFromToolResult', () => {
it('extracts file resources from create_file results', () => {
it('extracts file resources from create_empty_file results', () => {
const resources = extractResourcesFromToolResult(
'create_file',
'create_empty_file',
{
fileName: 'notes.md',
},
@@ -31,9 +31,9 @@ describe('extractResourcesFromToolResult', () => {
])
})
it('uses the knowledge base id for knowledge_base tag mutations', () => {
it('uses the knowledge base id for manage_knowledge_base tag mutations', () => {
const resources = extractResourcesFromToolResult(
'knowledge_base',
'manage_knowledge_base',
{
operation: 'update_tag',
args: {
@@ -63,7 +63,7 @@ describe('extractResourcesFromToolResult', () => {
it('uses knowledgeBaseId from the tool result when update_tag args omit it', () => {
const resources = extractResourcesFromToolResult(
'knowledge_base',
'manage_knowledge_base',
{
operation: 'update_tag',
args: {
@@ -93,7 +93,7 @@ describe('extractResourcesFromToolResult', () => {
it('does not create resources for read-only knowledge base tag operations', () => {
const resources = extractResourcesFromToolResult(
'knowledge_base',
'manage_knowledge_base',
{
operation: 'list_tags',
args: {
@@ -156,7 +156,7 @@ describe('extractDeletedResourcesFromToolResult', () => {
{ from: 'workflows/Lead%20Router', kind: 'workflow', id: 'wf-1' },
{ from: 'workflows/Old%20Projects', kind: 'workflow_folder', id: 'wfolder-1' },
{ from: 'tables/Leads', kind: 'table', id: 'tbl-1' },
{ from: 'knowledgebases/support-docs', kind: 'knowledge_base', id: 'kb-1' },
{ from: 'knowledgebases/support-docs', kind: 'manage_knowledge_base', id: 'kb-1' },
{ from: 'files/missing.md', kind: 'file', error: 'Not found: files/missing.md' },
],
}
@@ -181,10 +181,10 @@ describe('extractDeletedResourcesFromToolResult', () => {
).toEqual([{ type: 'table', id: 'table-1', title: 'Table' }])
})
it('extracts deleted knowledge bases from knowledge_base result data', () => {
it('extracts deleted knowledge bases from manage_knowledge_base result data', () => {
expect(
extractDeletedResourcesFromToolResult(
'knowledge_base',
'manage_knowledge_base',
{ operation: 'delete', args: { knowledgeBaseIds: ['kb-1'] } },
{
success: true,
+20 -20
View File
@@ -1,18 +1,18 @@
import {
CreateFile,
CreateEmptyFile,
CreateWorkflow,
DownloadToWorkspaceFile,
DownloadFile,
EditWorkflow,
Ffmpeg,
FunctionExecute,
GenerateAudio,
GenerateImage,
GenerateVideo,
Knowledge,
KnowledgeBase,
ManageKnowledgeBase,
PrepareFileEdit,
Rm,
RunFunction,
UserTable,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import type { MothershipResource, MothershipResourceType } from './types'
@@ -21,13 +21,13 @@ type ResourceType = MothershipResourceType
const RESOURCE_TOOL_NAMES: Set<string> = new Set([
UserTable.id,
CreateFile.id,
WorkspaceFile.id,
DownloadToWorkspaceFile.id,
CreateEmptyFile.id,
PrepareFileEdit.id,
DownloadFile.id,
CreateWorkflow.id,
EditWorkflow.id,
FunctionExecute.id,
KnowledgeBase.id,
RunFunction.id,
ManageKnowledgeBase.id,
Knowledge.id,
GenerateImage.id,
GenerateVideo.id,
@@ -110,8 +110,8 @@ export function extractResourcesFromToolResult(
return []
}
case CreateFile.id:
case WorkspaceFile.id: {
case CreateEmptyFile.id:
case PrepareFileEdit.id: {
const file = asRecord(data.file)
if (file.id) {
return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }]
@@ -124,7 +124,7 @@ export function extractResourcesFromToolResult(
return []
}
case FunctionExecute.id: {
case RunFunction.id: {
if (result.tableId) {
return [
{
@@ -146,7 +146,7 @@ export function extractResourcesFromToolResult(
return []
}
case DownloadToWorkspaceFile.id:
case DownloadFile.id:
case GenerateImage.id:
case GenerateVideo.id:
case GenerateAudio.id:
@@ -181,7 +181,7 @@ export function extractResourcesFromToolResult(
return []
}
case KnowledgeBase.id: {
case ManageKnowledgeBase.id: {
if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return []
const args = asRecord(params?.args)
@@ -225,9 +225,9 @@ export function extractResourcesFromToolResult(
}
const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record<string, ResourceType> = {
[WorkspaceFile.id]: 'file',
[PrepareFileEdit.id]: 'file',
[UserTable.id]: 'table',
[KnowledgeBase.id]: 'knowledgebase',
[ManageKnowledgeBase.id]: 'knowledgebase',
// rm spans categories, so unlike every other entry its resource type comes
// from each outcome's kind rather than from this map. The entry exists so
// hasDeleteCapability(rm) holds; the rm case below ignores this value.
@@ -241,7 +241,7 @@ const RM_KIND_RESOURCE_TYPE: Record<string, ResourceType> = {
workflow: 'workflow',
workflow_folder: 'folder',
table: 'table',
knowledge_base: 'knowledgebase',
manage_knowledge_base: 'knowledgebase',
}
export function hasDeleteCapability(toolName: string): boolean {
@@ -281,7 +281,7 @@ export function extractDeletedResourcesFromToolResult(
return [{ type, id, title: leaf ? decodeURIComponent(leaf) : 'Deleted resource' }]
})
}
case WorkspaceFile.id: {
case PrepareFileEdit.id: {
if (operation !== 'delete') return []
const target = getWorkspaceFileTarget(params)
const fileId = (data.id as string) ?? (target.fileId as string) ?? (args.fileId as string)
@@ -306,7 +306,7 @@ export function extractDeletedResourcesFromToolResult(
return []
}
case KnowledgeBase.id: {
case ManageKnowledgeBase.id: {
if (operation !== 'delete') return []
const deleted = Array.isArray(data.deleted) ? data.deleted : []
const resources = deleted.flatMap((entry): ChatResource[] => {
+2
View File
@@ -20,6 +20,8 @@ export interface MothershipResource {
id: string
title: string
path?: string
/** Saved table view to open pinned (type "table" only). */
viewId?: string
}
/**
@@ -48,25 +48,23 @@ describe('copilot tool executor fallback', () => {
isSimExecuted.mockReturnValue(true)
isClientExecuted.mockReturnValue(false)
const handler = vi.fn().mockResolvedValue({ success: true })
registerHandler('function_execute', handler)
registerHandler('run_function', handler)
await expect(
executeTool('function_execute', { code: 'return 1' }, { userId: 'user-1', workflowId: '' })
executeTool('run_function', { code: 'return 1' }, { userId: 'user-1', workflowId: '' })
).resolves.toEqual({
success: false,
error:
"Permission denied: function_execute requires write access. You have 'none' permission.",
error: "Permission denied: run_function requires write access. You have 'none' permission.",
})
await expect(
executeTool(
'function_execute',
'run_function',
{ code: 'return 1' },
{ userId: 'user-1', workflowId: '', userPermission: 'read' }
)
).resolves.toEqual({
success: false,
error:
"Permission denied: function_execute requires write access. You have 'read' permission.",
error: "Permission denied: run_function requires write access. You have 'read' permission.",
})
expect(handler).not.toHaveBeenCalled()
})
@@ -77,11 +75,11 @@ describe('copilot tool executor fallback', () => {
isSimExecuted.mockReturnValue(true)
isClientExecuted.mockReturnValue(false)
const handler = vi.fn().mockResolvedValue({ success: true, output: 'ok' })
registerHandler('function_execute', handler)
registerHandler('run_function', handler)
await expect(
executeTool(
'function_execute',
'run_function',
{ code: 'return 1' },
{ userId: 'user-1', workflowId: '', userPermission: 'write' }
)
@@ -263,13 +261,13 @@ describe('copilot tool executor fallback', () => {
expect(executeAppTool).toHaveBeenCalledWith('unknown_client_tool', expect.any(Object))
})
it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => {
it('converts run_function timeout from seconds to milliseconds for copilot calls', async () => {
isKnownTool.mockReturnValue(false)
isSimExecuted.mockReturnValue(false)
executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
await executeTool(
'function_execute',
'run_function',
{ code: 'return 1', timeout: 7 },
{
userId: 'user-1',
@@ -280,7 +278,7 @@ describe('copilot tool executor fallback', () => {
)
expect(executeAppTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
timeout: 7000,
_context: expect.objectContaining({
@@ -296,12 +294,12 @@ describe('copilot tool executor fallback', () => {
)
})
it('converts function_execute timeout before invoking its registered Sim handler', async () => {
it('converts run_function timeout before invoking its registered Sim handler', async () => {
isKnownTool.mockReturnValue(true)
isSimExecuted.mockReturnValue(true)
isClientExecuted.mockReturnValue(false)
const handler = vi.fn().mockResolvedValue({ success: true, output: { result: 'ok' } })
registerHandler('function_execute', handler)
registerHandler('run_function', handler)
const context = {
userId: 'user-1',
@@ -309,7 +307,7 @@ describe('copilot tool executor fallback', () => {
workspaceId: 'ws-1',
copilotToolExecution: true,
}
await executeTool('function_execute', { code: 'return 1', timeout: 7 }, context)
await executeTool('run_function', { code: 'return 1', timeout: 7 }, context)
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
@@ -321,13 +319,13 @@ describe('copilot tool executor fallback', () => {
expect(executeAppTool).not.toHaveBeenCalled()
})
it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => {
it('defaults copilot run_function timeout to 10 seconds when omitted', async () => {
isKnownTool.mockReturnValue(false)
isSimExecuted.mockReturnValue(false)
executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
await executeTool(
'function_execute',
'run_function',
{ code: 'return 1' },
{
userId: 'user-1',
@@ -338,7 +336,7 @@ describe('copilot tool executor fallback', () => {
)
expect(executeAppTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
timeout: 10_000,
}),
@@ -351,13 +349,13 @@ describe('copilot tool executor fallback', () => {
)
})
it('defaults copilot function_execute timeout to 10 seconds when invalid', async () => {
it('defaults copilot run_function timeout to 10 seconds when invalid', async () => {
isKnownTool.mockReturnValue(false)
isSimExecuted.mockReturnValue(false)
executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
await executeTool(
'function_execute',
'run_function',
{ code: 'return 1', timeout: 0 },
{
userId: 'user-1',
@@ -368,7 +366,7 @@ describe('copilot tool executor fallback', () => {
)
expect(executeAppTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
timeout: 10_000,
}),
@@ -381,13 +379,13 @@ describe('copilot tool executor fallback', () => {
)
})
it('does not let copilot function_execute timeout exceed the default execution limit', async () => {
it('does not let copilot run_function timeout exceed the default execution limit', async () => {
isKnownTool.mockReturnValue(false)
isSimExecuted.mockReturnValue(false)
executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } })
await executeTool(
'function_execute',
'run_function',
{ code: 'return 1', timeout: 10_000 },
{
userId: 'user-1',
@@ -398,7 +396,7 @@ describe('copilot tool executor fallback', () => {
)
expect(executeAppTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
timeout: DEFAULT_EXECUTION_TIMEOUT_MS,
}),
@@ -13,7 +13,7 @@ import type {
} from './types'
const logger = createLogger('ToolExecutor')
const FUNCTION_EXECUTE_TOOL_ID = 'function_execute'
const FUNCTION_EXECUTE_TOOL_ID = 'run_function'
const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10
const MILLISECONDS_PER_SECOND = 1000
@@ -1,42 +1,40 @@
import { createLogger } from '@sim/logger'
import {
CheckDeploymentStatus,
Cp as CpTool,
CreateWorkflow,
CreateWorkspaceMcpServer,
DeleteWorkspaceMcpServer,
DeployApi,
DeployChat,
DeployCustomBlock,
DeployMcp,
DeployAsApi,
DeployAsChat,
DeployAsMcp,
DiffWorkflows,
FunctionExecute,
GenerateApiKey,
GetBlockOutputs,
GetBlockUpstreamReferences,
GetDeployedWorkflowState,
GetDeploymentLog,
GetPlatformActions,
GetDeploymentStatus,
GetUiReference,
GetWorkflowData,
GetWorkflowRunOptions,
Glob as GlobTool,
Grep as GrepTool,
ListDeploymentVersions,
ListIntegrationTools,
ListUserWorkspaces,
ListWorkspaceMcpServers,
LoadDeployment,
ManageCredential,
ManageCustomTool,
ManageMcpTool,
ManageMcpConnection,
ManageSandbox,
ManageSkill,
MaterializeFile,
Mkdir as MkdirTool,
Mv as MvTool,
OauthGetAuthLink,
OauthRequestAccess,
OpenResource,
PromoteToLive,
PublishCustomBlock,
Read as ReadTool,
Redeploy,
RestoreResource,
@@ -44,8 +42,10 @@ import {
RunBlock,
RunCode,
RunFromBlock,
RunFunction,
RunWorkflow,
RunWorkflowUntilBlock,
SaveUpload,
SetBlockEnabled,
SetGlobalWorkflowVariables,
UpdateDeploymentVersion,
@@ -156,17 +156,17 @@ function buildHandlerMap(): Record<string, ToolHandler> {
[GenerateApiKey.id]: h(executeGenerateApiKey),
[SetGlobalWorkflowVariables.id]: h(executeSetGlobalWorkflowVariables),
[DeployApi.id]: h(executeDeployApi),
[DeployChat.id]: h(executeDeployChat),
[DeployMcp.id]: h(executeDeployMcp),
[DeployCustomBlock.id]: h(executeDeployCustomBlock),
[DeployAsApi.id]: h(executeDeployApi),
[DeployAsChat.id]: h(executeDeployChat),
[DeployAsMcp.id]: h(executeDeployMcp),
[PublishCustomBlock.id]: h(executeDeployCustomBlock),
[Redeploy.id]: h(executeRedeploy),
[CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus),
[GetDeploymentStatus.id]: h(executeCheckDeploymentStatus),
[ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers),
[CreateWorkspaceMcpServer.id]: h(executeCreateWorkspaceMcpServer),
[UpdateWorkspaceMcpServer.id]: h(executeUpdateWorkspaceMcpServer),
[DeleteWorkspaceMcpServer.id]: h(executeDeleteWorkspaceMcpServer),
[GetDeploymentLog.id]: h(executeGetDeploymentLog),
[ListDeploymentVersions.id]: h(executeGetDeploymentLog),
[DiffWorkflows.id]: h(executeDiffWorkflows),
[LoadDeployment.id]: h(executeLoadDeployment),
[PromoteToLive.id]: h(executePromoteToLive),
@@ -181,7 +181,7 @@ function buildHandlerMap(): Record<string, ToolHandler> {
[RmTool.id]: h(executeVfsRm),
[ManageCustomTool.id]: h(executeManageCustomTool),
[ManageMcpTool.id]: h(executeManageMcpTool),
[ManageMcpConnection.id]: h(executeManageMcpTool),
[ManageSandbox.id]: h(executeManageSandbox),
[ManageSkill.id]: h(executeManageSkill),
[ManageCredential.id]: h(executeManageCredential),
@@ -192,10 +192,10 @@ function buildHandlerMap(): Record<string, ToolHandler> {
[OauthRequestAccess.id]: h(executeOAuthRequestAccess),
[OpenResource.id]: h(executeOpenResource),
[RestoreResource.id]: h(executeRestoreResource),
[GetPlatformActions.id]: h(executeGetPlatformActions),
[GetUiReference.id]: h(executeGetPlatformActions),
[ListIntegrationTools.id]: h(executeListIntegrationTools),
[MaterializeFile.id]: h(executeMaterializeFile),
[FunctionExecute.id]: h(executeFunctionExecute),
[SaveUpload.id]: h(executeMaterializeFile),
[RunFunction.id]: h(executeFunctionExecute),
[RunCode.id]: h(executeRunCode),
...buildServerToolHandlers(),
@@ -173,8 +173,8 @@ describe('resolveToolDisplay', () => {
})
it('falls back to a humanized tool label for generic tools', () => {
expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe(
'Executed Deploy API'
expect(resolveToolDisplay('deploy_as_api', ClientToolCallState.success)?.text).toBe(
'Executed Deploy As API'
)
expect(resolveToolDisplay('oauth-integrations', ClientToolCallState.success)?.text).toBe(
'Executed OAuth Integrations'
@@ -33,7 +33,7 @@ describe('getCopilotDeploymentIdempotencyKey', () => {
it('separates deployment intents within the same execution', () => {
const context = { executionId: 'execution-1', toolCallId: 'call-1' }
expect(getCopilotDeploymentIdempotencyKey(context, 'deploy_api')).not.toBe(
expect(getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api')).not.toBe(
getCopilotDeploymentIdempotencyKey(context, 'redeploy')
)
})
@@ -192,7 +192,7 @@ describe('executeDeployCustomBlock', () => {
const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('deploy_api')
expect(result.error).toContain('deploy_as_api')
expect(publishCustomBlockMock).not.toHaveBeenCalled()
})
@@ -262,7 +262,7 @@ export async function executeDeployCustomBlock(
return {
success: false,
error:
'Workflow must be deployed before publishing as a custom block. Use deploy_api first.',
'Workflow must be deployed before publishing as a custom block. Use deploy_as_api first.',
}
}
// Curation is required on publish: every consumer-visible field must be one
@@ -134,7 +134,7 @@ describe('deployment handlers', () => {
expect.any(Object),
expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }),
expect.objectContaining({
idempotencyKey: 'copilot:execution-1:operation:deploy_api',
idempotencyKey: 'copilot:execution-1:operation:deploy_as_api',
})
)
})
@@ -170,7 +170,7 @@ describe('deployment handlers', () => {
expect.any(Object),
expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }),
expect.objectContaining({
idempotencyKey: 'copilot:execution-1:operation:deploy_api',
idempotencyKey: 'copilot:execution-1:operation:deploy_as_api',
})
)
})
@@ -211,7 +211,7 @@ export async function executeDeployApi(
description: versionDescription,
name: versionName,
requestId: generateRequestId(),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to deploy workflow' }
@@ -377,7 +377,7 @@ export async function executeDeployChat(
includeThinking: params.includeThinking,
includeToolCalls: params.includeToolCalls,
requestId: generateRequestId(),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_chat'),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_chat'),
})
const baseUrl = getBaseUrl()
@@ -591,7 +591,7 @@ export async function executeRedeploy(
description: versionDescription,
name: versionName,
requestId: generateRequestId(),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_api'),
idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'),
})
if (!result.success) {
return { success: false, error: result.error || 'Failed to redeploy workflow' }
@@ -511,7 +511,8 @@ export async function executeUpdateDeploymentVersion(
if (version === null) {
return {
success: false,
error: 'version must be a deployment version number (use get_deployment_log to find it)',
error:
'version must be a deployment version number (use list_deployment_versions to find it)',
}
}
@@ -247,7 +247,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
)
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
envVars: { API_KEY: 'secret-value' },
secretScope: 'selected',
@@ -272,7 +272,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled()
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }),
{ resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) }
)
@@ -295,7 +295,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
names: ['TOKEN'],
},
])(
'uses the shared $language compiler analysis before delegating source to function_execute',
'uses the shared $language compiler analysis before delegating source to run_function',
async ({ language, code, names }) => {
await executeFunctionExecute({ language, code }, context as never)
@@ -305,14 +305,14 @@ describe('executeFunctionExecute trace-secret provenance', () => {
requestedNames: names,
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({ code, language, mountedSecrets: names }),
{ resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) }
)
}
)
it('routes run_code shell commands through the same function_execute boundary', async () => {
it('routes run_code shell commands through the same run_function boundary', async () => {
const code = 'printf %s "{{CLI_TOKEN}}"'
const abortController = new AbortController()
@@ -332,7 +332,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
requestedNames: ['CLI_TOKEN'],
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }),
{
resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry),
@@ -342,7 +342,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
)
})
it('uses the trusted Mothership profile for function_execute without accepting a param override', async () => {
it('uses the trusted Mothership profile for run_function without accepting a param override', async () => {
await executeFunctionExecute(
{
code: 'return 1',
@@ -353,7 +353,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
)
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({
_context: expect.not.objectContaining({ sandboxProfile: expect.anything() }),
}),
@@ -373,7 +373,7 @@ describe('executeFunctionExecute trace-secret provenance', () => {
expect(mockHasWorkspaceSandboxAccess).toHaveBeenCalledWith('ws_1')
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
'run_function',
expect.objectContaining({ sandboxId: 'sandbox-1' }),
expect.objectContaining({ internalSandboxProfile: 'mothership' })
)
@@ -1216,7 +1216,7 @@ describe('executeFunctionExecute unmountable namespaces', () => {
it('keeps the uploads/ guidance intact', async () => {
const message = await mountError({ inputFiles: ['uploads/report.json'] })
expect(message).toContain('materialize_file')
expect(message).toContain('save_upload')
})
it('still reports a genuine files/ miss as not found', async () => {
@@ -254,10 +254,10 @@ function unmountableNamespaceReason(filePath: string): string | null {
const path = `${filePath.replace(/^\/+|\/+$/g, '')}/`
if (path.startsWith('uploads/')) {
return 'uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.'
return 'uploads/ files are not mountable into the sandbox. Use save_upload to save it to a files/... path first, then mount that canonical path.'
}
if (path.startsWith('internal/tool-results/')) {
return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (function_execute: outputs.files[].path, user_table: outputPath) and mount that files/... path.'
return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.'
}
if (path.startsWith('internal/')) {
return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.'
@@ -417,7 +417,7 @@ export async function resolveInputFiles(
`Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.`
)
}
logger.info('Mounting workspace directory for function_execute', {
logger.info('Mounting workspace directory for run_function', {
vfsPath: dirPath,
sandboxPath: mountRoot,
fileCount: descendants.length,
@@ -745,7 +745,7 @@ export async function executeFunctionExecute(
}
try {
const result = await executeAppTool('function_execute', enrichedParams, {
const result = await executeAppTool('run_function', enrichedParams, {
resolvedSecretTraceRegistry: mountedRegistry,
...(context.abortSignal ? { signal: context.abortSignal } : {}),
...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}),
@@ -45,7 +45,7 @@ export async function executeManageCustomTool(
* workspace — so a caller could name another workspace and have it
* authorized against their own. `upsertCustomTools` does no authz of its own
* (it only scopes queries by the id it is handed), so nothing downstream
* caught it. Matches manage_mcp_tool and manage_skill.
* caught it. Matches manage_mcp_connection and manage_skill.
*/
const workspaceId = context.workspaceId
@@ -184,12 +184,15 @@ export async function executeManageMcpTool(
}
}
return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` }
return {
success: false,
error: `Unsupported operation for manage_mcp_connection: ${operation}`,
}
} catch (error) {
logger.error(
context.messageId
? `manage_mcp_tool execution failed [messageId:${context.messageId}]`
: 'manage_mcp_tool execution failed',
? `manage_mcp_connection execution failed [messageId:${context.messageId}]`
: 'manage_mcp_connection execution failed',
{
operation,
workspaceId,
@@ -223,19 +223,19 @@ describe('executeMaterializeFile - unsupported operation', () => {
)
expect(result.success).toBe(false)
expect(result.error).toContain('Unsupported materialize_file operation "table"')
expect(result.error).toContain('Unsupported save_upload operation "table"')
expect(result.error).toContain('table subagent')
expect(mockFindUpload).not.toHaveBeenCalled()
})
it('rejects the knowledge_base operation and points to the knowledge subagent', async () => {
it('rejects the manage_knowledge_base operation and points to the knowledge subagent', async () => {
const result = await executeMaterializeFile(
{ fileNames: ['data.csv'], operation: 'knowledge_base' },
{ fileNames: ['data.csv'], operation: 'manage_knowledge_base' },
context
)
expect(result.success).toBe(false)
expect(result.error).toContain('Unsupported materialize_file operation "knowledge_base"')
expect(result.error).toContain('Unsupported save_upload operation "manage_knowledge_base"')
expect(result.error).toContain('knowledge subagent')
expect(mockFindUpload).not.toHaveBeenCalled()
})
@@ -45,7 +45,7 @@ import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/crea
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
import { extractWorkflowMetadata } from '@/app/api/v1/admin/types'
const logger = createLogger('MaterializeFile')
const logger = createLogger('SaveUpload')
const MAX_MATERIALIZE_NAME_RETRIES = 8
const WORKSPACE_FILE_NAME_UNIQUE_INDEX = 'workspace_files_workspace_folder_name_active_unique'
@@ -102,7 +102,7 @@ async function executeSave(
if (isArchiveFileName(displayName)) {
return {
success: false,
error: `"${fileName}" is a .zip archive — save it by extracting instead: materialize_file(fileNames: ["${fileName}"], operation: "extract") unpacks it into files/ where the contents stay readable. The raw .zip remains in uploads/ for this chat.`,
error: `"${fileName}" is a .zip archive — save it by extracting instead: save_upload(fileNames: ["${fileName}"], operation: "extract") unpacks it into files/ where the contents stay readable. The raw .zip remains in uploads/ for this chat.`,
}
}
@@ -256,7 +256,7 @@ async function executeImport(
if (isArchiveFileName(row.displayName ?? row.originalName)) {
return {
success: false,
error: `"${fileName}" is a .zip archive, not a workflow JSON. Extract it first: materialize_file(fileNames: ["${fileName}"], operation: "extract").`,
error: `"${fileName}" is a .zip archive, not a workflow JSON. Extract it first: save_upload(fileNames: ["${fileName}"], operation: "extract").`,
}
}
@@ -553,11 +553,11 @@ export async function executeMaterializeFile(
}
if (!context.chatId) {
return { success: false, error: 'No chat context available for materialize_file' }
return { success: false, error: 'No chat context available for save_upload' }
}
if (!context.workspaceId) {
return { success: false, error: 'No workspace context available for materialize_file' }
return { success: false, error: 'No workspace context available for save_upload' }
}
const principal = resolveCopilotFilePrincipal(context)
@@ -569,7 +569,7 @@ export async function executeMaterializeFile(
if (operation !== 'save' && operation !== 'import' && operation !== 'extract') {
return {
success: false,
error: `Unsupported materialize_file operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
error: `Unsupported save_upload operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
}
}
@@ -615,7 +615,7 @@ export async function executeMaterializeFile(
failed.push({ fileName, error: result.error ?? 'Failed to materialize file' })
}
} catch (err) {
logger.error('materialize_file failed', {
logger.error('save_upload failed', {
fileName,
operation,
chatId: context.chatId,
@@ -302,6 +302,8 @@ export interface OpenResourceItem {
type?: OpenResourceType
id?: string
path?: string
/** Saved-view id or exact name to open a table pinned to (table type only). */
view?: string
}
export interface OpenResourceParams {
@@ -315,4 +317,5 @@ export interface ValidOpenResourceParams {
type: OpenResourceType
id?: string
path?: string
view?: string
}
@@ -1,5 +1,5 @@
/**
* Static content for the get_platform_actions tool.
* Static content for the get_ui_reference tool.
* Contains the Sim platform quick reference and keyboard shortcuts.
*/
export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts
@@ -39,6 +39,10 @@ vi.mock('@/lib/table/service', () => ({
getTableById: vi.fn(),
}))
vi.mock('@/lib/table/views/service', () => ({
getTableView: vi.fn(),
}))
vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({
readKnowledgeBase: {
operation: { id: 'knowledge.read' },
@@ -86,6 +90,8 @@ vi.mock('@/lib/logs/service', () => ({
getLogById: vi.fn(),
}))
import { getTableById } from '@/lib/table/service'
import { getTableView } from '@/lib/table/views/service'
import { executeOpenResource } from './resources'
describe('executeOpenResource', () => {
@@ -229,3 +235,52 @@ describe('executeOpenResource', () => {
).rejects.toThrow('knowledge database unavailable')
})
})
describe('open_resource table views', () => {
const executionContext = { userId: 'user-1', workspaceId: 'ws-1' } as never
it('opens a table pinned to a saved view by id, stamping viewId and a pinned title', async () => {
vi.mocked(getTableById).mockResolvedValue({
id: 'tbl-1',
name: 'Leads',
workspaceId: 'ws-1',
schema: { columns: [{ id: 'col_a', name: 'status', type: 'string' }] },
} as never)
vi.mocked(getTableView).mockResolvedValue({
id: 'view-1',
name: 'Overdue',
isDefault: false,
config: {},
} as never)
const result = await executeOpenResource(
{ resources: [{ type: 'table', id: 'tbl-1', view: 'view-1' }] },
executionContext
)
expect(result.success).toBe(true)
expect(result.resources?.[0]).toMatchObject({
type: 'table',
id: 'tbl-1',
title: 'Leads — Overdue',
viewId: 'view-1',
})
})
it('rejects an unknown view id and points at views.json', async () => {
vi.mocked(getTableById).mockResolvedValue({
id: 'tbl-1',
name: 'Leads',
workspaceId: 'ws-1',
schema: { columns: [] },
} as never)
vi.mocked(getTableView).mockResolvedValue(null as never)
const missing = await executeOpenResource(
{ resources: [{ type: 'table', id: 'tbl-1', view: 'view-nope' }] },
executionContext
)
expect(missing.success).toBe(false)
expect((missing.output as { errors: string[] }).errors[0]).toContain('views.json')
})
})
@@ -6,7 +6,9 @@ import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases'
import { getLogById } from '@/lib/logs/service'
import type { TableSchema } from '@/lib/table'
import { getTableById } from '@/lib/table/service'
import { getTableView } from '@/lib/table/views/service'
import {
findWorkspaceFileRecord,
type WorkspaceFileRecord,
@@ -76,6 +78,21 @@ async function resolveResource(
return { error: `Table not found in the current workspace.` }
resourceId = tbl.id
title = tbl.name
if (item.view) {
const view = await getTableView(
item.view.trim(),
tbl.id,
(tbl.schema as TableSchema).columns,
context.workspaceId ?? undefined
)
if (!view) {
return {
error: `No view with id "${item.view.trim()}" on table "${tbl.name}". View ids are listed in the table's views.json.`,
}
}
title = `${tbl.name} — ${view.name}`
return { type: resourceType, id: resourceId, title, viewId: view.id }
}
}
if (resourceType === 'knowledgebase') {
if (!item.id) return { error: 'knowledgebase resources require `id`.' }
@@ -174,5 +191,8 @@ function validateOpenResourceItem(
if (!item.id && !(item.type === 'file' && item.path)) {
return { success: false, error: `${item.type} resources require \`id\`` }
}
return { success: true, params: { type: item.type, id: item.id, path: item.path } }
return {
success: true,
params: { type: item.type, id: item.id, path: item.path, view: item.view },
}
}
@@ -2,7 +2,7 @@ import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/to
import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute'
/**
* Compute-only variant of function_execute for info-gathering agents: same
* Compute-only variant of run_function for info-gathering agents: same
* sandbox and inputs, but it must never create or overwrite workspace
* resources. The write vectors (outputs.files, outputTable) are rejected here
* on top of the Go executor's fail-fast guard; run_code is also absent from
@@ -184,7 +184,7 @@ describe('readChatUpload', () => {
const result = await readChatUpload('bundle.zip', CHAT_ID)
expect(result?.content).toContain('materialize_file')
expect(result?.content).toContain('save_upload')
expect(result?.content).toContain('extract')
expect(mockReadFileRecord).not.toHaveBeenCalled()
})
@@ -200,7 +200,7 @@ describe('readChatUpload', () => {
const result = await readChatUpload('huge.zip', CHAT_ID)
expect(result?.content).toContain('materialize_file')
expect(result?.content).toContain('save_upload')
expect(mockFetchBuffer).not.toHaveBeenCalled()
expect(mockReadFileRecord).not.toHaveBeenCalled()
})
@@ -243,7 +243,7 @@ describe('grepChatUpload', () => {
const error = await grepChatUpload('bundle.zip', CHAT_ID, 'foo').catch((e) => e)
expect(error).toBeInstanceOf(WorkspaceFileGrepError)
expect(error.message).toContain('materialize_file')
expect(error.message).toContain('save_upload')
expect(error.message).toContain('extract')
expect(mockReadFileRecord).not.toHaveBeenCalled()
})
@@ -321,13 +321,13 @@ describe('vfs mv/cp', () => {
expect(result.error).toContain('across categories')
})
it('rejects uploads with a materialize_file pointer', async () => {
it('rejects uploads with a save_upload pointer', async () => {
const result = await executeVfsMv(
{ sources: ['uploads/data.csv'], destination: 'files/data.csv' },
context
)
expect(result.success).toBe(false)
expect(result.error).toContain('materialize_file')
expect(result.error).toContain('save_upload')
})
it('rejects read-only categories', async () => {
@@ -54,7 +54,7 @@ const MUTATE_CATEGORIES = new Set<string>(['files', 'workflows', 'tables', 'know
const CATEGORY_REJECTIONS: Record<string, string> = {
uploads:
'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.',
'uploads/ files are chat-scoped and immutable. Use save_upload to promote one into files/ first.',
'recently-deleted':
'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.',
}
@@ -34,8 +34,8 @@ describe('copilotWriteDeniedMessage', () => {
})
it('omits the operation label when there is no operation', () => {
expect(copilotWriteDeniedMessage('knowledge_base', undefined, 'read')).toBe(
"Permission denied: knowledge_base requires write access. You have 'read' permission."
expect(copilotWriteDeniedMessage('manage_knowledge_base', undefined, 'read')).toBe(
"Permission denied: manage_knowledge_base requires write access. You have 'read' permission."
)
})
})
@@ -24,7 +24,7 @@ describe('server tool adapter authority boundary', () => {
})
it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => {
const handler = createServerToolHandler('workspace_file')
const handler = createServerToolHandler('prepare_file_edit')
await handler(
{ workspaceId: 'attacker-workspace', operation: 'rename' },
@@ -39,7 +39,7 @@ describe('server tool adapter authority boundary', () => {
)
expect(mocks.routeExecution).toHaveBeenCalledWith(
'workspace_file',
'prepare_file_edit',
expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }),
expect.objectContaining({
userId: 'user-1',
@@ -55,7 +55,7 @@ describe('server tool adapter authority boundary', () => {
const storageError = new Error('update workspace_files set secret_column = value')
mocks.routeExecution.mockRejectedValue(storageError)
const result = await createServerToolHandler('workspace_file')(
const result = await createServerToolHandler('prepare_file_edit')(
{},
{
userId: 'user-1',
@@ -68,13 +68,13 @@ describe('server tool adapter authority boundary', () => {
expect(result).toEqual({
success: false,
error: `[workspace_file] ${TOOL_RESULT_UNAVAILABLE_ERROR}`,
error: `[prepare_file_edit] ${TOOL_RESULT_UNAVAILABLE_ERROR}`,
})
expect(result.error).not.toContain('workspace_files')
expect(mocks.loggerError).toHaveBeenCalledWith(
'Server tool execution failed',
{
toolId: 'workspace_file',
toolId: 'prepare_file_edit',
abortSignalAborted: false,
},
storageError
@@ -16,7 +16,7 @@ export interface ServerToolContext {
messageId?: string
/**
* The invoking subagent's channel id (its outer tool_use id). Used to scope
* the workspace_file -> edit_content intent handoff to a single file subagent
* the prepare_file_edit -> apply_file_edit intent handoff to a single file subagent
* so two file agents writing concurrently never consume each other's pending
* intent. Undefined for main-agent tool calls (which never overlap).
*/
@@ -9,7 +9,7 @@ const { mockGenerateSearchEmbedding } = vi.hoisted(() => ({
}))
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
SearchDocumentation: { id: 'search_documentation' },
SearchSimDocs: { id: 'search_sim_docs' },
}))
vi.mock('@/lib/knowledge/embeddings', () => ({
generateSearchEmbedding: mockGenerateSearchEmbedding,
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { docsEmbeddings } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sql } from 'drizzle-orm'
import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1'
import { SearchSimDocs } from '@/lib/copilot/generated/tool-catalog-v1'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
@@ -15,7 +15,7 @@ interface DocsSearchParams {
const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3
export const searchDocumentationServerTool: BaseServerTool<DocsSearchParams, any> = {
name: SearchDocumentation.id,
name: SearchSimDocs.id,
async execute(params: DocsSearchParams): Promise<any> {
const logger = createLogger('SearchDocumentationServerTool')
const { query, topK = 10, threshold } = params
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { EnrichmentRun } from '@/lib/copilot/generated/tool-catalog-v1'
import { RunEnrichment } from '@/lib/copilot/generated/tool-catalog-v1'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getEnrichment } from '@/enrichments/registry'
import { runEnrichment } from '@/enrichments/run'
@@ -25,7 +25,7 @@ interface EnrichmentRunResult {
* bill (see image/generate-image.ts).
*/
export const enrichmentRunServerTool: BaseServerTool<EnrichmentRunParams, EnrichmentRunResult> = {
name: EnrichmentRun.id,
name: RunEnrichment.id,
async execute(params: EnrichmentRunParams, context): Promise<EnrichmentRunResult> {
const logger = createLogger('EnrichmentRunServerTool')
const { enrichmentId, inputs } = params
@@ -13,7 +13,7 @@ import {
} from '@/lib/workspace-files/application/write-workspace-file-by-path'
const logger = createLogger('CreateFileServerTool')
const CREATE_FILE_TOOL_ID = 'create_file'
const CREATE_FILE_TOOL_ID = 'create_empty_file'
interface CreateFileArgs {
fileName: string
@@ -48,7 +48,10 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
const explicitType = params.contentType || (nested?.contentType as string) || undefined
const outputFile = params.outputs?.files?.[0]
if (!outputFile?.path && !fileName) {
return { success: false, message: 'create_file requires outputs.files[0].path or fileName' }
return {
success: false,
message: 'create_empty_file requires outputs.files[0].path or fileName',
}
}
const outputPath =
outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`)
@@ -77,7 +80,7 @@ export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResu
exactName: true,
})
logger.info('File created via create_file', {
logger.info('File created via create_empty_file', {
fileId: result.id,
name: result.vfsPath,
contentType,
@@ -353,7 +353,7 @@ async function compileDocViaE2BPython(
// `pptx`/`docx` instances, geometry constants, and fileId-based image helpers
// (reading staged /home/user/inputs/<id> files). pptx also gets `iconImage`
// (react-icons → sharp → PNG), which only works here because the E2B sandbox is
// a full Linux VM. The agent's edit_content source runs inside an async IIFE so
// a full Linux VM. The agent's apply_file_edit source runs inside an async IIFE so
// top-level await (addImage/iconImage) works; the finalizer writes the binary.
const PPTX_NODE_PREAMBLE = `
const PptxGenJS = require('pptxgenjs');
@@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { z } from 'zod'
import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case'
import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation'
import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { DownloadFile } from '@/lib/copilot/generated/tool-catalog-v1'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -137,7 +137,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
DownloadToWorkspaceFileArgs,
DownloadToWorkspaceFileResult
> = {
name: DownloadToWorkspaceFile.id,
name: DownloadFile.id,
inputSchema: DownloadToWorkspaceFileArgsSchema,
outputSchema: DownloadToWorkspaceFileResultSchema,
@@ -30,10 +30,10 @@ type EditContentResult = {
}
export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentResult> = {
name: 'edit_content',
name: 'apply_file_edit',
async execute(params: EditContentArgs, context?: ServerToolContext): Promise<EditContentResult> {
if (!context?.userId) {
logger.error('Unauthorized attempt to use edit_content')
logger.error('Unauthorized attempt to use apply_file_edit')
throw new Error('Authentication required')
}
@@ -52,12 +52,12 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
: undefined
if (content === undefined) {
return { success: false, message: 'content is required for edit_content' }
return { success: false, message: 'content is required for apply_file_edit' }
}
// Consume the intent from THIS file subagent's channel (its outer tool_use
// id), not just the latest in the message — otherwise two file agents
// writing concurrently would each grab whichever workspace_file landed last
// writing concurrently would each grab whichever prepare_file_edit landed last
// and write their content into the wrong file. Falls back to latest-in-
// message when no channel id is present (main-agent / legacy calls).
const intent = await consumeLatestFileIntent(workspaceId, {
@@ -69,7 +69,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
return {
success: false,
message:
'No workspace_file context found. Call workspace_file first, wait for it to succeed, then call edit_content in the next step. Do not emit edit_content in parallel or in the same batch as workspace_file.',
'No prepare_file_edit context found. Call prepare_file_edit first, wait for it to succeed, then call apply_file_edit in the next step. Do not emit apply_file_edit in parallel or in the same batch as prepare_file_edit.',
}
}
@@ -259,7 +259,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
const verb =
operation === 'append' ? 'appended to' : operation === 'update' ? 'updated' : 'patched'
logger.info(`Workspace file ${verb} via copilot (edit_content)`, {
logger.info(`Workspace file ${verb} via copilot (apply_file_edit)`, {
fileId: intent.fileId,
name: fileRecord.name,
operation,
@@ -284,7 +284,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
} catch (error) {
const safeMessage = messageForCopilotFileError(error, 'Failed to edit file content')
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
logger.error('Error in edit_content tool', {
logger.error('Error in apply_file_edit tool', {
operation: intent.operation,
fileId: intent.fileId,
error: errorMessage,
@@ -47,11 +47,11 @@ describe('file-intent-store channel scoping', () => {
})
)
// edit_content from channel F1 must get fileA — NOT the latest (fileB).
// apply_file_edit from channel F1 must get fileA — NOT the latest (fileB).
const a = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' })
expect(a?.fileId).toBe('fileA')
// edit_content from channel F2 gets fileB.
// apply_file_edit from channel F2 gets fileB.
const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' })
expect(b?.fileId).toBe('fileB')
})
@@ -12,7 +12,7 @@ export type PendingFileIntent = {
chatId?: string
messageId?: string
// The invoking file subagent's channel id (its outer tool_use id). Lets
// edit_content consume the intent for ITS OWN file subagent instead of the
// apply_file_edit consume the intent for ITS OWN file subagent instead of the
// latest in the message, so two file agents writing concurrently never cross
// their content into each other's file.
channelId?: string
@@ -135,9 +135,9 @@ function buildAppendPreview(existingContent: string, incomingContent: string): s
/**
* Reads the current UTF-8 text of a workspace file for streaming previews.
*
* Preview runs in the SSE loop on `workspace_file` **call** events, which are
* Preview runs in the SSE loop on `prepare_file_edit` **call** events, which are
* processed **before** the async tool executor persists {@link storeFileIntent}.
* Loading the base here avoids a race where `edit_content` `args_delta` arrives
* Loading the base here avoids a race where `apply_file_edit` `args_delta` arrives
* before Redis holds `existingContent`, which would make append previews look like
* full-file replacement until the intent landed.
*/
@@ -193,7 +193,7 @@ export function buildFilePreviewText({
// Fail closed (like `patch`/`update` below) when the base file content has not loaded yet: a base-less
// `append` preview is just the streamed fragment, and a collaborative editor applying it as the full
// body would reconcile the seeded doc down to that fragment (a wipe). Skipping the preview until the
// base is available costs only a brief render delay; the final durable `edit_content` write is
// base is available costs only a brief render delay; the final durable `apply_file_edit` write is
// authoritative. An empty file has `existingContent === ''` (defined), so it is unaffected.
if (existingContent === undefined) {
return undefined
@@ -10,7 +10,7 @@ import {
messageForCopilotFileError,
resolveCopilotFilePrincipal,
} from '@/lib/copilot/auth/file-delegation'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1'
import {
assertServerToolNotAborted,
type BaseServerTool,
@@ -191,7 +191,7 @@ export type CompileForWriteResult =
| { ok: false; message: string }
/**
* Shared write-time doc handling for create + edit_content: validates and builds
* Shared write-time doc handling for create + apply_file_edit: validates and builds
* the document (E2B doc sandbox when enabled — Node pptx/docx, Python pdf/xlsx —
* else isolated-vm JS) and returns the source MIME to store, or a user-facing
* failure message. Non-doc files resolve to `fallbackMime`. The remote backend publishes a
@@ -260,7 +260,7 @@ export async function compileDocForWrite(args: {
}
export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
name: WorkspaceFile.id,
name: PrepareFileEdit.id,
async execute(
params: WorkspaceFileArgs,
context?: ServerToolContext
@@ -478,7 +478,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
return {
success: true,
message: withMessageId(
`Intent set: append to "${existingFile.name}". Wait for this success result, then call edit_content in the next step with the content to write. Do not call edit_content in parallel.`
`Intent set: append to "${existingFile.name}". Wait for this success result, then call apply_file_edit in the next step with the content to write. Do not call apply_file_edit in parallel.`
),
data: { id: existingFile.id, name: existingFile.name, vfsPath, operation: 'append' },
}
@@ -506,7 +506,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
return {
success: true,
message: withMessageId(
`Intent set: update "${fileRecord.name}". Wait for this success result, then call edit_content in the next step with the replacement content. Do not call edit_content in parallel.`
`Intent set: update "${fileRecord.name}". Wait for this success result, then call apply_file_edit in the next step with the replacement content. Do not call apply_file_edit in parallel.`
),
data: { id: fileRecord.id, name: fileRecord.name, vfsPath, operation: 'update' },
}
@@ -710,7 +710,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
return {
success: true,
message: withMessageId(
`Intent set: patch "${fileRecord.name}" (${normalized.edit.strategy}). Wait for this success result, then call edit_content in the next step with the replacement/insert content. Do not call edit_content in parallel.`
`Intent set: patch "${fileRecord.name}" (${normalized.edit.strategy}). Wait for this success result, then call apply_file_edit in the next step with the replacement/insert content. Do not call apply_file_edit in parallel.`
),
data: { id: fileRecord.id, name: fileRecord.name, vfsPath, operation: 'patch' },
}
@@ -724,7 +724,7 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
logger.error('Error in workspace_file tool', {
logger.error('Error in prepare_file_edit tool', {
operation,
error: errorMessage,
userId: context.userId,
@@ -81,7 +81,7 @@ const {
})
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
KnowledgeBase: { id: 'knowledge_base' },
ManageKnowledgeBase: { id: 'manage_knowledge_base' },
}))
vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: {
@@ -224,7 +224,7 @@ function expectDelegatedPrincipal(call: unknown): void {
})
}
describe('knowledge_base trusted application delegation', () => {
describe('manage_knowledge_base trusted application delegation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' })
@@ -754,7 +754,7 @@ describe('knowledge_base trusted application delegation', () => {
)
})
describe('knowledge_base add_file delegation', () => {
describe('manage_knowledge_base add_file delegation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAddWorkspaceFiles.mockResolvedValue({
@@ -10,7 +10,7 @@ import {
messageForCopilotKnowledgeError,
requireCopilotKnowledgeWorkspaceId,
} from '@/lib/copilot/application/execute-knowledge-use-case'
import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1'
import { ManageKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1'
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import {
assertServerToolNotAborted,
@@ -252,7 +252,7 @@ function isKnowledgeDocumentTagValueAssignment(
* Knowledge base tool for copilot to create, list, and get knowledge bases
*/
export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, KnowledgeBaseResult> = {
name: KnowledgeBase.id,
name: ManageKnowledgeBase.id,
async execute(
params: KnowledgeBaseArgs,
context?: ServerToolContext
@@ -1070,7 +1070,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
logger.error('Error in knowledge_base tool', {
logger.error('Error in manage_knowledge_base tool', {
operation,
error: projectToolErrorMessageForCopilot(errorMessage, context.resolvedSecretTraceRegistry),
userId: context.userId,
@@ -16,7 +16,7 @@ type SearchKnowledgeBaseResult = {
const READ_OPERATIONS = new Set(['get', 'query', 'list_tags'])
/**
* Read-only variant of knowledge_base for info-gathering agents. Copilot
* Read-only variant of manage_knowledge_base for info-gathering agents. Copilot
* access control is a per-agent tool allowlist, so read-only access gets its
* own tool name with its own operation contract — enforced here (where
* execution happens) on top of the fail-fast guard in the Go executor.
@@ -31,7 +31,7 @@ export const searchKnowledgeBaseServerTool: BaseServerTool<
if (!READ_OPERATIONS.has(operation)) {
return {
success: false,
message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`,
message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's manage_knowledge_base tool`,
}
}
return knowledgeBaseServerTool.execute(params, context)
@@ -9,7 +9,7 @@ const { mockExecuteTool } = vi.hoisted(() => ({
}))
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
SearchOnline: { id: 'search_online' },
WebSearch: { id: 'web_search' },
}))
vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { SearchOnline } from '@/lib/copilot/generated/tool-catalog-v1'
import { WebSearch } from '@/lib/copilot/generated/tool-catalog-v1'
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
@@ -31,7 +31,7 @@ interface SearchResponse {
}
export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchResponse> = {
name: SearchOnline.id,
name: WebSearch.id,
async execute(params: OnlineSearchParams, context?: ServerToolContext): Promise<SearchResponse> {
const logger = createLogger('SearchOnlineServerTool')
const { query, num = 10, type = 'search', gl, hl } = params
+12 -10
View File
@@ -2,19 +2,19 @@ import { createLogger } from '@sim/logger'
import { z } from 'zod'
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
import {
CreateFile,
DownloadToWorkspaceFile,
CreateEmptyFile,
DownloadFile,
Ffmpeg,
GenerateAudio,
GenerateImage,
GenerateVideo,
KnowledgeBase,
ManageCredential,
ManageCustomTool,
ManageMcpTool,
ManageKnowledgeBase,
ManageMcpConnection,
ManageSkill,
PrepareFileEdit,
UserTable,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import { copilotToolCanWrite } from '@/lib/copilot/tools/permissions'
import {
@@ -53,6 +53,7 @@ import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-c
import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments'
import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage'
import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows'
import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views'
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials'
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
@@ -90,7 +91,7 @@ const VISIBILITY_GATED_TOOLS = new Set([
])
const WRITE_ACTIONS: Record<string, string[]> = {
[KnowledgeBase.id]: [
[ManageKnowledgeBase.id]: [
'create',
'add_file',
'update',
@@ -133,19 +134,19 @@ const WRITE_ACTIONS: Record<string, string[]> = {
'add_enrichment',
],
[ManageCustomTool.id]: ['add', 'edit', 'delete'],
[ManageMcpTool.id]: ['add', 'edit', 'delete'],
[ManageMcpConnection.id]: ['add', 'edit', 'delete'],
[ManageSkill.id]: ['add', 'edit', 'delete'],
[ManageCredential.id]: ['rename', 'delete'],
[WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'],
[PrepareFileEdit.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'],
[editContentServerTool.name]: ['*'],
[CreateFile.id]: ['*'],
[CreateEmptyFile.id]: ['*'],
rename_file: ['*'],
[shareFileServerTool.name]: ['*'],
move_file: ['*'],
create_file_folder: ['*'],
rename_file_folder: ['*'],
move_file_folder: ['*'],
[DownloadToWorkspaceFile.id]: ['*'],
[DownloadFile.id]: ['*'],
[GenerateImage.id]: ['generate'],
[GenerateVideo.id]: ['generate'],
[GenerateAudio.id]: ['generate'],
@@ -182,6 +183,7 @@ const baseServerToolRegistry: Record<string, BaseServerTool> = {
[tableColumnsServerTool.name]: tableColumnsServerTool,
[tableAutomationsServerTool.name]: tableAutomationsServerTool,
[tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool,
[tableViewsServerTool.name]: tableViewsServerTool,
[workspaceFileServerTool.name]: workspaceFileServerTool,
[editContentServerTool.name]: editContentServerTool,
[createFileServerTool.name]: createFileServerTool,
@@ -0,0 +1,123 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const useCases = vi.hoisted(() => ({
list: vi.fn(),
read: vi.fn(),
create: vi.fn(),
update: vi.fn(),
del: vi.fn(),
}))
vi.mock('@/lib/table/application/views', () => ({
listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: useCases.list },
readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.read },
createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: useCases.create },
updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update },
deleteTableViewUseCase: { operation: { id: 'tables.views.delete' }, execute: useCases.del },
}))
const executeUseCase = vi.hoisted(() => vi.fn())
vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({
executeCopilotTableUseCase: executeUseCase,
}))
import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views'
const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never
const columns = [
{ id: 'col_a', name: 'status', type: 'string' },
{ id: 'col_b', name: 'due', type: 'date' },
]
const table = { id: 'tbl-1', schema: { columns } }
describe('table_views adapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('translates stored id-domain configs to column names on list', async () => {
executeUseCase.mockResolvedValueOnce({
table,
views: [
{
id: 'view-1',
name: 'Overdue',
isDefault: true,
config: {
filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] },
sort: [{ field: 'col_b', direction: 'asc' }],
},
},
],
})
const result = await tableViewsServerTool.execute(
{ operation: 'list_views', args: { tableId: 'tbl-1' } },
context
)
expect(result.success).toBe(true)
expect(result.data.views[0].filter).toEqual({
all: [{ field: 'status', op: 'ne', value: 'Done' }],
})
expect(result.data.views[0].sort).toEqual([{ field: 'due', direction: 'asc' }])
})
it('translates agent column names to stable ids on create', async () => {
executeUseCase.mockResolvedValueOnce({ table, views: [] }).mockResolvedValueOnce({
view: { id: 'view-2', name: 'Mine', isDefault: false, config: {} },
table,
})
const result = await tableViewsServerTool.execute(
{
operation: 'create_view',
args: {
tableId: 'tbl-1',
name: 'Mine',
filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] },
},
},
context
)
expect(result.success).toBe(true)
const createInput = executeUseCase.mock.calls[1][2]
expect(createInput.config.filter).toEqual({
all: [{ field: 'col_a', op: 'eq', value: 'Open' }],
})
})
it('rejects unknown column names with the columns spelled out', async () => {
executeUseCase.mockResolvedValueOnce({ table, views: [] })
await expect(
tableViewsServerTool.execute(
{
operation: 'create_view',
args: {
tableId: 'tbl-1',
name: 'Broken',
filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] },
},
},
context
)
).rejects.toThrow(/Unknown column/)
})
it('rejects unsupported operations without invoking anything', async () => {
const result = await tableViewsServerTool.execute(
{ operation: 'insert_row', args: { tableId: 'tbl-1' } },
context
)
expect(result.success).toBe(false)
expect(result.message).toContain('insert_row')
expect(executeUseCase).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,197 @@
import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case'
import { TableViews } from '@/lib/copilot/generated/tool-catalog-v1'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table'
import {
createTableViewUseCase,
deleteTableViewUseCase,
listTableViewsUseCase,
readTableViewUseCase,
updateTableViewUseCase,
} from '@/lib/table/application/views'
import { viewConfigIdsToNames, viewConfigNamesToIds } from '@/lib/table/views/service'
type TableViewsArgs = {
operation: string
args?: Record<string, any>
}
type TableViewsResult = {
success: boolean
message: string
data?: any
}
/**
* Saved-view slice of the split table surface. Unlike the other slices this is
* NOT a user_table passthrough — it adapts the dedicated view use cases.
* Agents speak column NAMES; stored configs are keyed by stable column id, so
* inputs translate names→ids on the way in and every returned view translates
* ids→names on the way out.
*/
export const tableViewsServerTool: BaseServerTool<TableViewsArgs, TableViewsResult> = {
name: TableViews.id,
async execute(params: TableViewsArgs, context?: ServerToolContext) {
const operation = params?.operation
const args = params?.args ?? {}
const tableId = args.tableId as string | undefined
const workspaceId = context?.workspaceId
if (!tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const presentView = (
view: { id: string; name: string; isDefault: boolean; config: TableViewConfig },
columns: TableSchema['columns']
) => {
const named = viewConfigIdsToNames(view.config, columns)
return {
id: view.id,
name: view.name,
isDefault: view.isDefault,
filter: named.filter ?? null,
sort: named.sort ?? null,
hiddenColumns: named.hiddenColumns?.length ? named.hiddenColumns : undefined,
}
}
const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig =>
viewConfigNamesToIds(
{
filter: (args.filter as TablePredicateInput | undefined) ?? null,
sort: (args.sort as SortSpec | undefined) ?? null,
hiddenColumns: args.hiddenColumns as string[] | undefined,
} as TableViewConfig,
columns
)
switch (operation) {
case 'list_views': {
const result = await executeCopilotTableUseCase(
context,
listTableViewsUseCase,
{ tableId, workspaceId },
{ tableId }
)
const columns = (result.table.schema as TableSchema).columns
const views = result.views.map((view) => presentView(view, columns))
return {
success: true,
message: `Table has ${views.length} view(s)`,
data: { views },
}
}
case 'get_view': {
if (!args.viewId) return { success: false, message: 'viewId is required' }
const result = await executeCopilotTableUseCase(
context,
readTableViewUseCase,
{ tableId, workspaceId, viewId: args.viewId },
{ tableId }
)
const columns = (result.table.schema as TableSchema).columns
return {
success: true,
message: 'View loaded',
data: { view: presentView(result.view, columns) },
}
}
case 'create_view': {
if (!args.name) return { success: false, message: 'name is required' }
const listed = await executeCopilotTableUseCase(
context,
listTableViewsUseCase,
{ tableId, workspaceId },
{ tableId }
)
const columns = (listed.table.schema as TableSchema).columns
const created = await executeCopilotTableUseCase(
context,
createTableViewUseCase,
{ tableId, workspaceId, name: args.name, config: namedConfigFromArgs(columns) },
{ tableId }
)
if (args.isDefault === true) {
await executeCopilotTableUseCase(
context,
updateTableViewUseCase,
{ tableId, workspaceId, viewId: created.view.id, isDefault: true },
{ tableId }
)
}
return {
success: true,
message: `Created view "${created.view.name}" (${created.view.id})${args.isDefault === true ? ' as default' : ''}`,
data: {
view: presentView({ ...created.view, isDefault: args.isDefault === true }, columns),
},
}
}
case 'update_view': {
if (!args.viewId) return { success: false, message: 'viewId is required' }
const listed = await executeCopilotTableUseCase(
context,
listTableViewsUseCase,
{ tableId, workspaceId },
{ tableId }
)
const columns = (listed.table.schema as TableSchema).columns
const hasConfigChange =
args.filter !== undefined || args.sort !== undefined || args.hiddenColumns !== undefined
const updated = await executeCopilotTableUseCase(
context,
updateTableViewUseCase,
{
tableId,
workspaceId,
viewId: args.viewId,
name: args.name as string | undefined,
...(hasConfigChange ? { configPatch: namedConfigFromArgs(columns) } : {}),
isDefault: args.isDefault as boolean | undefined,
},
{ tableId }
)
return {
success: true,
message: `Updated view "${updated.view.name}"`,
data: { view: presentView(updated.view, columns) },
}
}
case 'delete_view': {
if (!args.viewId) return { success: false, message: 'viewId is required' }
const result = await executeCopilotTableUseCase(
context,
deleteTableViewUseCase,
{ tableId, workspaceId, viewId: args.viewId },
{ tableId }
)
return { success: true, message: `Deleted view "${result.viewName}"` }
}
case 'set_default_view': {
if (!args.viewId) return { success: false, message: 'viewId is required' }
const listed = await executeCopilotTableUseCase(
context,
listTableViewsUseCase,
{ tableId, workspaceId },
{ tableId }
)
const columns = (listed.table.schema as TableSchema).columns
const updated = await executeCopilotTableUseCase(
context,
updateTableViewUseCase,
{ tableId, workspaceId, viewId: args.viewId, isDefault: true },
{ tableId }
)
return {
success: true,
message: `"${updated.view.name}" is now the default view`,
data: { view: presentView(updated.view, columns) },
}
}
default:
return {
success: false,
message: `table_views does not support operation '${operation}' (allowed: list_views, get_view, create_view, update_view, delete_view, set_default_view)`,
}
}
},
}
@@ -604,7 +604,7 @@ describe('userTableServerTool.import_file', () => {
expect(mockBatchInsertRows).not.toHaveBeenCalled()
})
it('points a chat-upload path at materialize_file instead of globbing files/', async () => {
it('points a chat-upload path at save_upload instead of globbing files/', async () => {
mockResolveWorkspaceFileReference.mockResolvedValueOnce(null)
const result = await userTableServerTool.execute(
@@ -616,7 +616,7 @@ describe('userTableServerTool.import_file', () => {
)
expect(result.success).toBe(false)
expect(result.message).toMatch(/materialize_file/)
expect(result.message).toMatch(/save_upload/)
expect(result.message).not.toMatch(/glob\("files/)
})
@@ -48,6 +48,7 @@ import {
readTableUseCase,
updateTableUseCase,
} from '@/lib/table/application/tables'
import { readTableViewUseCase } from '@/lib/table/application/views'
import { namedRowMapper } from '@/lib/table/cell-format'
import { isSupportedCurrencyCode } from '@/lib/table/currency'
import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate'
@@ -56,11 +57,13 @@ import { normalizeSelectOptionsInput } from '@/lib/table/select-options'
import type {
RowData,
SortSpec,
TablePredicate,
TablePredicateInput,
TableSchema,
WorkflowGroupDependencies,
WorkflowGroupDeploymentMode,
} from '@/lib/table/types'
import { viewConfigIdsToNames } from '@/lib/table/views/service'
import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry'
const logger = createLogger('UserTableServerTool')
@@ -145,6 +148,19 @@ async function importRowsProvenanceForModel(
await registry.importCrossingProvenance(provenance, values, { trusted: true })
}
/** AND-combines a saved view's predicate with an explicit one; either may be absent. */
function mergeViewPredicate(
viewFilter: TablePredicateInput | undefined,
explicit: TablePredicateInput | undefined
): TablePredicate | undefined {
const parts: TablePredicate[] = []
if (viewFilter) parts.push(normalizeTablePredicate(viewFilter))
if (explicit) parts.push(normalizeTablePredicate(explicit))
if (parts.length === 0) return undefined
if (parts.length === 1) return parts[0]
return { all: parts }
}
export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> = {
name: UserTable.id,
async execute(params: UserTableArgs, context?: ServerToolContext): Promise<UserTableResult> {
@@ -398,6 +414,31 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return { success: false, message: 'Workspace ID is required' }
}
// Saved-view scope: the view's stored filter ANDs with any explicit
// filter (query-within-the-view), its sort applies only when no
// explicit order is given, and layout fields (hidden columns, order,
// widths) are ignored — agents always see full rows. Views are
// referenced by id only (from views.json or table_views list_views).
let viewFilter: TablePredicateInput | undefined
let viewSort: SortSpec | undefined
let appliedViewName: string | undefined
if (typeof args.view === 'string' && args.view.trim() !== '') {
const viewId = (args.view as string).trim()
const resolved = await executeCopilotTableUseCase(
context,
readTableViewUseCase,
{ tableId: args.tableId, workspaceId, viewId },
{ tableId: args.tableId }
)
const named = viewConfigIdsToNames(
resolved.view.config,
(resolved.table.schema as TableSchema).columns
)
viewFilter = (named.filter as TablePredicateInput | null) ?? undefined
viewSort = (named.sort as SortSpec | null) ?? undefined
appliedViewName = resolved.view.name
}
const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT)
if (queryLimitError) {
return { success: false, message: queryLimitError }
@@ -409,10 +450,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
{
tableId: args.tableId,
assertedWorkspaceId: workspaceId,
predicate: args.filter
? normalizeTablePredicate(args.filter as TablePredicateInput)
: undefined,
sort: args.order as SortSpec | undefined,
predicate: mergeViewPredicate(
viewFilter,
args.filter as TablePredicateInput | undefined
),
sort: (args.order as SortSpec | undefined) ?? viewSort,
limit: args.limit ?? TABLE_LIMITS.MAX_QUERY_LIMIT,
cursor: args.cursor,
includeTotal: !args.cursor,
@@ -431,7 +473,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
// nextCursor covers both cut kinds (explicit limit or the 5MB byte
// budget) — either way the truthful signal is "more rows exist". The
// token is opaque; the agent echoes it back as `cursor` to continue.
const countSuffix = result.totalCount != null ? ` of ${result.totalCount}` : ''
const viewSuffix = appliedViewName ? ` (view: ${appliedViewName})` : ''
const countSuffix =
(result.totalCount != null ? ` of ${result.totalCount}` : '') + viewSuffix
const message = result.nextCursor
? `Returned ${result.rows.length}${countSuffix} rows (more available — pass cursor=${result.nextCursor} to continue)`
: `Returned ${result.rows.length}${countSuffix} rows`
+31 -29
View File
@@ -4,9 +4,9 @@
import { describe, expect, it } from 'vitest'
import {
FfmpegOperationValues,
KnowledgeBaseOperationValues,
MaterializeFileOperationValues,
ManageKnowledgeBaseOperationValues,
QueryUserTableOperationValues,
SaveUploadOperationValues,
SearchKnowledgeBaseOperationValues,
TOOL_CATALOG,
type ToolCatalogEntry,
@@ -65,22 +65,22 @@ describe('humanizeToolName', () => {
it('keeps canonical acronym casing', () => {
expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server')
expect(humanizeToolName('deploy_api')).toBe('Deploy API')
expect(humanizeToolName('deploy_as_api')).toBe('Deploy As API')
expect(humanizeToolName('oauth_request_access')).toBe('OAuth Request Access')
})
})
describe('getToolDisplayTitle natural-language coverage', () => {
it('gives gerund titles to tools that previously fell through to humanize', () => {
expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API')
expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying API')
expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers')
expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link')
expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows')
})
it('falls back to running code for function_execute without a title', () => {
expect(getToolDisplayTitle('function_execute')).toBe('Running code')
expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe(
it('falls back to running code for run_function without a title', () => {
expect(getToolDisplayTitle('run_function')).toBe('Running code')
expect(getToolDisplayTitle('run_function', { title: 'Crunching numbers' })).toBe(
'Crunching numbers'
)
})
@@ -141,14 +141,14 @@ describe('getToolDisplayTitle natural-language coverage', () => {
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'],
['deploy_as_api', undefined, 'Deploying API'],
['deploy_as_api', { action: 'deploy' }, 'Deploying API'],
['deploy_as_api', { action: 'undeploy' }, 'Undeploying API'],
['deploy_as_chat', { action: 'deploy' }, 'Deploying chat'],
['deploy_as_chat', { action: 'undeploy' }, 'Undeploying chat'],
['publish_custom_block', { action: 'deploy' }, 'Deploying custom block'],
['publish_custom_block', { action: 'undeploy' }, 'Undeploying custom block'],
['deploy_as_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)
@@ -216,19 +216,21 @@ describe('mvDisplayVerb', () => {
describe('getToolDisplayTitle for the vfs verbs', () => {
it('shows the created file name', () => {
expect(
getToolDisplayTitle('create_file', {
getToolDisplayTitle('create_empty_file', {
outputs: {
files: [{ path: 'files/Reports/Quarterly%20Report.pdf', mode: 'create' }],
},
})
).toBe('Creating Quarterly Report.pdf')
expect(getToolDisplayTitle('create_file', { fileName: 'notes.md' })).toBe('Creating notes.md')
expect(getToolDisplayTitle('create_empty_file', { fileName: 'notes.md' })).toBe(
'Creating notes.md'
)
expect(
getToolDisplayTitle('create_file', {
getToolDisplayTitle('create_empty_file', {
outputs: { files: [{ path: 'files/notes.md', mode: 'overwrite' }] },
})
).toBe('Overwriting notes.md')
expect(getToolDisplayTitle('create_file')).toBe('Creating file')
expect(getToolDisplayTitle('create_empty_file')).toBe('Creating file')
})
it('titles rm from toolTitle, falling back to the paths', () => {
@@ -305,7 +307,7 @@ describe('getToolDisplayTitle for managed resources', () => {
},
'Creating lookupWeather',
],
['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'],
['manage_mcp_connection', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'],
['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'],
[
'manage_credential',
@@ -318,7 +320,7 @@ describe('getToolDisplayTitle for managed resources', () => {
],
['rm', { paths: ['workflows/Marketing/Q3%20Campaigns'] }, 'Deleting Q3 Campaigns'],
['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'],
['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'],
['manage_mcp_connection', { operation: 'list' }, 'Viewing MCP servers'],
['manage_skill', { operation: 'list' }, 'Viewing skills'],
])('uses verb + resource name for %s', (toolName, args, expected) => {
expect(getToolDisplayTitle(toolName, args)).toBe(expected)
@@ -335,15 +337,15 @@ describe('getToolDisplayTitle for operation-driven tools', () => {
})
it('covers every knowledge-base operation with its actual verb and resource', () => {
for (const operation of KnowledgeBaseOperationValues) {
expect(getToolDisplayTitle('knowledge_base', { operation })).not.toBe(
for (const operation of ManageKnowledgeBaseOperationValues) {
expect(getToolDisplayTitle('manage_knowledge_base', { operation })).not.toBe(
'Managing knowledge base'
)
}
expect(getToolDisplayTitle('knowledge_base', { operation: 'query' })).toBe(
expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'query' })).toBe(
'Searching knowledge base'
)
expect(getToolDisplayTitle('knowledge_base', { operation: 'sync_connector' })).toBe(
expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'sync_connector' })).toBe(
'Syncing knowledge base connector'
)
})
@@ -381,19 +383,19 @@ describe('getToolDisplayTitle for operation-driven tools', () => {
})
it('distinguishes saving uploads from importing workflows', () => {
for (const operation of MaterializeFileOperationValues) {
for (const operation of SaveUploadOperationValues) {
expect(
getToolDisplayTitle('materialize_file', { operation, fileNames: ['Lead Router.json'] })
getToolDisplayTitle('save_upload', { operation, fileNames: ['Lead Router.json'] })
).not.toBe('Preparing file')
}
expect(
getToolDisplayTitle('materialize_file', {
getToolDisplayTitle('save_upload', {
operation: 'save',
fileNames: ['Quarterly Report.pdf'],
})
).toBe('Saving Quarterly Report.pdf')
expect(
getToolDisplayTitle('materialize_file', {
getToolDisplayTitle('save_upload', {
operation: 'import',
fileNames: ['Lead Router.json'],
})
+34 -32
View File
@@ -449,36 +449,37 @@ const TOOL_TITLES: Record<string, string> = {
table_columns: 'Editing table columns',
table_automations: 'Managing table automations',
table_enrichments: 'Managing table enrichments',
workspace_file: 'Editing file',
edit_content: 'Applying file content',
table_views: 'Managing table views',
prepare_file_edit: 'Editing file',
apply_file_edit: 'Applying file content',
create_workflow: 'Creating workflow',
edit_workflow: 'Editing workflow',
knowledge_base: 'Managing knowledge base',
manage_knowledge_base: 'Managing knowledge base',
search_knowledge_base: 'Searching knowledge base',
open_resource: 'Opening resource',
generate_image: 'Generating image',
generate_video: 'Generating video',
generate_audio: 'Generating audio',
ffmpeg: 'Processing media',
check_deployment_status: 'Checking deployment status',
create_file: 'Creating file',
get_deployment_status: 'Checking deployment status',
create_empty_file: 'Creating file',
create_file_folder: 'Creating folder',
create_workspace_mcp_server: 'Creating MCP server',
delete_workspace_mcp_server: 'Deleting MCP server',
deploy_api: 'Deploying API',
deploy_chat: 'Deploying chat',
deploy_custom_block: 'Deploying custom block',
deploy_mcp: 'Deploying MCP tool',
deploy_as_api: 'Deploying API',
deploy_as_chat: 'Deploying chat',
publish_custom_block: 'Deploying custom block',
deploy_as_mcp: 'Deploying MCP tool',
diff_workflows: 'Comparing workflows',
download_to_workspace_file: 'Downloading file',
function_execute: 'Running code',
download_file: 'Downloading file',
run_function: 'Running code',
complete_scheduled_task: 'Completing scheduled task',
generate_api_key: 'Generating API key',
get_block_outputs: 'Getting block outputs',
get_block_upstream_references: 'Getting block references',
get_deployed_workflow_state: 'Getting deployed workflow',
get_deployment_log: 'Getting deployment logs',
get_platform_actions: 'Getting platform actions',
list_deployment_versions: 'Getting deployment logs',
get_ui_reference: 'Getting platform actions',
get_scheduled_task_logs: 'Reading scheduled task logs',
get_workflow_data: 'Getting workflow data',
get_workflow_run_options: 'Getting run options',
@@ -487,7 +488,7 @@ const TOOL_TITLES: Record<string, string> = {
list_user_workspaces: 'Listing workspaces',
list_workspace_mcp_servers: 'Listing MCP servers',
load_deployment: 'Loading deployment',
materialize_file: 'Preparing file',
save_upload: 'Preparing file',
manage_sandbox: 'Managing sandbox',
manage_scheduled_task: 'Managing scheduled task',
move_file: 'Moving file',
@@ -503,8 +504,7 @@ const TOOL_TITLES: Record<string, string> = {
restore_resource: 'Restoring resource',
run_block: 'Running block',
scheduled_task: 'Managing scheduled task',
search_documentation: 'Searching documentation',
search_patterns: 'Searching patterns',
search_sim_docs: 'Searching documentation',
set_block_enabled: 'Toggling block',
set_environment_variables: 'Setting environment variables',
set_global_workflow_variables: 'Setting workflow variables',
@@ -532,7 +532,7 @@ const TOOL_TITLES: Record<string, string> = {
auth: 'Auth Agent',
knowledge: 'Knowledge Agent',
table: 'Table Agent',
agent: 'Tools Agent',
extensions: 'Extensions Agent',
research: 'Research Agent',
scout: 'Scout Agent',
search: 'Search Agent',
@@ -676,15 +676,15 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
}
switch (name) {
case 'deploy_api':
case 'deploy_as_api':
return deploymentTitle(args, 'API')
case 'deploy_chat':
case 'deploy_as_chat':
return deploymentTitle(args, 'chat')
case 'deploy_custom_block':
case 'publish_custom_block':
return deploymentTitle(args, 'custom block')
case 'ffmpeg':
return ffmpegTitle(args)
case 'knowledge_base':
case 'manage_knowledge_base':
return knowledgeBaseTitle(args)
case 'query_user_table':
case 'table_manage':
@@ -692,6 +692,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
case 'table_columns':
case 'table_automations':
case 'table_enrichments':
case 'table_views':
return queryUserTableTitle(args)
case 'search_knowledge_base':
return searchKnowledgeBaseTitle(args)
@@ -701,7 +702,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
return manageScheduledTaskTitle(args)
case 'user_table':
return userTableTitle(args)
case 'materialize_file':
case 'save_upload':
return materializeFileTitle(args)
case 'open_resource':
return openResourceTitle(args)
@@ -771,7 +772,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
}
case 'set_global_workflow_variables':
return setGlobalWorkflowVariablesTitle(args)
case 'create_file':
case 'create_empty_file':
return createFileTitle(args)
case 'share_file': {
const action = stringArg(args, 'action') || 'share'
@@ -799,7 +800,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
const target = firstStringArg(args, 'serverName', 'name', 'title')
return `Deleting ${target || 'MCP server'}`
}
case 'search_online': {
case 'web_search': {
const target = firstStringArg(args, 'toolTitle', 'title')
return target ? `Searching online for ${target}` : 'Searching online'
}
@@ -838,7 +839,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource')
return target ? `Deleting ${target}` : 'Deleting'
}
case 'enrichment_run': {
case 'run_enrichment': {
const subject = nestedStringArg(
args,
'inputs',
@@ -850,7 +851,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
)
return subject ? `Searching for ${subject}` : 'Searching'
}
case 'scrape_page': {
case 'web_scrape': {
const url = stringArg(args, 'url')
return url ? `Scraping ${url}` : 'Scraping page'
}
@@ -886,11 +887,11 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
const reason = stringArg(args, 'reason')
return reason ? `Waiting for you: ${reason}` : 'Waiting for you in the browser'
}
case 'crawl_website': {
case 'web_crawl': {
const url = stringArg(args, 'url')
return url ? `Crawling ${url}` : 'Crawling website'
}
case 'get_page_contents': {
case 'web_fetch': {
const urls = stringArrayArg(args, 'urls')
if (urls.length === 1) return `Getting ${urls[0]}`
if (urls.length > 1) return `Getting ${urls.length} pages`
@@ -910,7 +911,7 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
list: { verb: 'Viewing', resource: 'custom tools' },
})
}
case 'manage_mcp_tool': {
case 'manage_mcp_connection': {
const target =
firstStringArg(args, 'serverName', 'name', 'title') ||
nestedStringArg(args, 'config', 'name')
@@ -957,9 +958,10 @@ export function getToolDisplayTitle(name: string, args?: Record<string, unknown>
}
break
}
case 'workspace_file':
case 'function_execute': {
const title = name === 'workspace_file' ? workspaceFileTitle(args) : stringArg(args, 'title')
case 'prepare_file_edit':
case 'run_function': {
const title =
name === 'prepare_file_edit' ? workspaceFileTitle(args) : stringArg(args, 'title')
if (title) return title
break
}
+1 -1
View File
@@ -146,7 +146,7 @@ export async function writeWorkspaceFileByPath(args: {
/**
* Forwarded to {@link updateWorkspaceFileContent} on an overwrite. Defaults to `true` (stream a
* markdown overwrite into any open collaborative editor). Pass `false` for a write whose content is
* only a placeholder — e.g. `create_file`'s empty shell, whose real content lands via a later write.
* only a placeholder — e.g. `create_empty_file`'s empty shell, whose real content lands via a later write.
*/
syncLiveDoc?: boolean
/** Private provenance for the exact bytes being written. */
+35
View File
@@ -1221,3 +1221,38 @@ export function serializeTriggerOverview(
lines.push('')
return lines.join('\n')
}
/**
* tables/{name}/views.json — the table's saved views in the column-NAME
* domain agents speak (stored configs are id-keyed; the caller translates).
* Layout-only fields (order, widths, pinned) are omitted: they are UI
* concerns and never change which rows a view selects.
*/
export function serializeTableViews(
views: Array<{
id: string
name: string
isDefault: boolean
filter?: unknown
sort?: unknown
hiddenColumns?: string[]
updatedAt: Date | string
}>
): string {
return JSON.stringify(
{
views: views.map((view) => ({
id: view.id,
name: view.name,
isDefault: view.isDefault,
filter: view.filter ?? null,
sort: view.sort ?? null,
hiddenColumns: view.hiddenColumns?.length ? view.hiddenColumns : undefined,
updatedAt: view.updatedAt instanceof Date ? view.updatedAt.toISOString() : view.updatedAt,
})),
note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: "<view id>"}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).',
},
null,
2
)
}
+36 -1
View File
@@ -87,6 +87,7 @@ import {
serializeSandboxCatalog,
serializeSkill,
serializeTableMeta,
serializeTableViews,
serializeTriggerOverview,
serializeTriggerSchema,
serializeVersions,
@@ -125,6 +126,12 @@ import { validateMermaidSource } from '@/lib/mermaid/validate'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { listTables } from '@/lib/table/service'
import {
listTableViewsByWorkspace,
normalizeStoredViewConfig,
pruneViewConfig,
viewConfigIdsToNames,
} from '@/lib/table/views/service'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import type {
@@ -1941,15 +1948,43 @@ export class WorkspaceVFS {
*/
private async materializeTables(workspaceId: string): Promise<WorkspaceMdData['tables']> {
try {
const [tables, folderPaths] = await Promise.all([
const [tables, folderPaths, viewsByTable] = await Promise.all([
listTables(workspaceId),
this.registerResourceFolders(workspaceId, 'table', 'tables'),
listTableViewsByWorkspace(workspaceId),
])
for (const table of tables) {
const safeName = sanitizeName(table.name)
const folderPath = table.folderId ? folderPaths.get(table.folderId) : undefined
const prefix = folderPath ? `tables/${folderPath}/${safeName}` : `tables/${safeName}`
const viewRows = viewsByTable.get(table.id) ?? []
if (viewRows.length > 0) {
const columns = table.schema.columns
this.files.set(
`${prefix}/views.json`,
serializeTableViews(
viewRows.map((row) => {
const config = viewConfigIdsToNames(
pruneViewConfig(
normalizeStoredViewConfig(row.config as Record<string, unknown>),
columns
),
columns
)
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
filter: config.filter ?? null,
sort: config.sort ?? null,
hiddenColumns: config.hiddenColumns,
updatedAt: row.updatedAt,
}
})
)
)
}
this.files.set(
`${prefix}/meta.json`,
serializeTableMeta({
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More