mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
Align Copilot tools and resource handling
This commit is contained in:
+2
-2
@@ -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+)"/)
|
||||
|
||||
+9
-9
@@ -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()
|
||||
})
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+10
-10
@@ -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.
|
||||
|
||||
@@ -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/')
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user