From ac108e454f4afe93d518addbe9f411a318b8ee9f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 10:58:07 -0700 Subject: [PATCH 001/135] checkpoint --- .../copilot/request/tools/executor.test.ts | 58 ++++++++- .../sim/lib/copilot/request/tools/executor.ts | 7 +- .../server/knowledge/knowledge-base.test.ts | 42 ++++++ .../tools/server/knowledge/knowledge-base.ts | 61 ++++++++- .../knowledge/application/documents.test.ts | 122 ++++++++++++++++++ .../lib/knowledge/application/documents.ts | 84 +++++++++++- 6 files changed, 366 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 06c86e224fa..58b62d95f8e 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -73,7 +73,12 @@ vi.mock('@/lib/copilot/request/tools/workflow-context', () => ({ })) import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolExecutionContext, @@ -330,6 +335,57 @@ describe('executeToolAndReport provenance isolation', () => { expect(registry.getActiveMatches()).toEqual([]) expect(JSON.stringify([completion, onEvent.mock.calls])).not.toContain('secret-value') }) + + it('reveals a generated API key only in the live client event', async () => { + const generatedKey = 'sk-sim-one-time-secret' + const statusMessage = 'API key "streaming-test" created.' + executeTool.mockResolvedValueOnce({ + success: true, + output: { + id: 'key-1', + name: 'streaming-test', + key: generatedKey, + workspaceId: 'workspace-1', + message: statusMessage, + }, + }) + const toolCall: ToolCallState = { + id: 'generate-key-call', + name: GenerateApiKey.id, + status: 'pending', + params: { name: 'streaming-test' }, + } + + const completion = await executeToolAndReport( + toolCall.id, + buildStreamingContext(toolCall), + { userId: 'user-1', workflowId: 'workflow-1' }, + { onEvent } + ) + + expect(completion).toEqual({ + status: MothershipStreamV1ToolOutcome.success, + message: 'Tool completed', + data: statusMessage, + }) + expect(completeAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ result: statusMessage }) + ) + expect(JSON.stringify([completion, completeAsyncToolCall.mock.calls])).not.toContain( + generatedKey + ) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolName: GenerateApiKey.id, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: expect.objectContaining({ key: generatedKey }), + }), + }) + ) + }) }) describe('executeToolAndReport metrics', () => { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 693a8c325d2..abab3329518 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -32,6 +32,7 @@ import { EditContent, Ffmpeg, FunctionExecute, + GenerateApiKey, GenerateAudio, GenerateImage, GenerateVideo, @@ -809,6 +810,10 @@ async function executeToolAndReportInner( // Fire-and-forget: notify the copilot backend that the tool completed. // IMPORTANT: We must NOT await this — the Go backend may block on the + const clientEventOutput = + toolCall.name === GenerateApiKey.id && hasOutputValue(copilotResult) + ? copilotResult.output + : terminalData const resultEvent: StreamEvent = { type: MothershipStreamV1EventType.tool, payload: { @@ -818,7 +823,7 @@ async function executeToolAndReportInner( mode: MothershipStreamV1ToolMode.async, phase: MothershipStreamV1ToolPhase.result, success: modelSucceeded, - output: terminalData, + output: clientEventOutput, ...(modelSucceeded ? { status: MothershipStreamV1ToolOutcome.success } : { status: MothershipStreamV1ToolOutcome.error, error: terminalMessage }), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index f4eff85a65f..8d1d5ba941f 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -565,6 +565,48 @@ describe('knowledge_base trusted application delegation', () => { }) }) + it('delegates per-document typed tag values by tag definition ID', async () => { + mockUpdateKnowledgeDocument.mockResolvedValueOnce({ + document: {}, + updatedFields: ['tag1', 'number1'], + }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + ], + }, + }, + CONTEXT + ) + + expect(result).toMatchObject({ + success: true, + data: { + documentId: 'document-1', + tagDefinitionIds: ['category-tag', 'priority-tag'], + }, + }) + const call = mockUpdateKnowledgeDocument.mock.calls[0][0] + expectDelegatedPrincipal(call) + expect(call.input).toEqual({ + knowledgeBaseId: KNOWLEDGE_BASE.id, + documentId: 'document-1', + assertedWorkspaceId: 'workspace-paid', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + ], + source: 'agent', + }) + }) + it('does not expose connector infrastructure errors to the model', async () => { mockUpdateKnowledgeConnector.mockRejectedValueOnce(new Error('sql host=private-db')) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 2e97eaf547d..1f5aa48d4c5 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -30,6 +30,7 @@ import { } from '@/lib/knowledge/application/connectors' import { bulkDeleteKnowledgeDocuments, + type KnowledgeDocumentTagValueAssignment, updateKnowledgeDocument, } from '@/lib/knowledge/application/documents' import { @@ -46,7 +47,7 @@ import { readKnowledgeTagUsage, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' -import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' +import { ALL_TAG_SLOTS, KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' import { captureServerEvent } from '@/lib/posthog/server' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' @@ -230,6 +231,23 @@ type KnowledgeBaseResult = { data?: any } +function isKnowledgeDocumentTagValueAssignment( + value: unknown +): value is KnowledgeDocumentTagValueAssignment { + if (typeof value !== 'object' || value === null) return false + const assignment = value as Record + if (typeof assignment.tagDefinitionId !== 'string' || !assignment.tagDefinitionId.trim()) { + return false + } + if (!Object.hasOwn(assignment, 'value')) return false + return ( + assignment.value === null || + typeof assignment.value === 'string' || + typeof assignment.value === 'number' || + typeof assignment.value === 'boolean' + ) +} + /** * Knowledge base tool for copilot to create, list, and get knowledge bases */ @@ -613,17 +631,42 @@ export const knowledgeBaseServerTool: BaseServerTool ALL_TAG_SLOTS.length) { + return { + success: false, + message: `Too many tag values (${args.tagValues.length}). Maximum is ${ALL_TAG_SLOTS.length}.`, + } + } + updateData.tagValues = args.tagValues + } if (Object.keys(updateData).length === 0) { return { success: false, - message: 'At least one of filename or enabled is required for update_document', + message: + 'At least one of filename, enabled, or tagValues is required for update_document', } } assertNotAborted() @@ -641,7 +684,17 @@ export const knowledgeBaseServerTool: BaseServerTool assignment.tagDefinitionId + ), + }), }, } } diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 8df17daf2ee..41190fff76f 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ recordKnowledgeBaseFileOwnership: vi.fn(), recordAudit: vi.fn(), captureServerEvent: vi.fn(), + getDocumentTagDefinitions: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -71,6 +72,10 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ getProcessingConfig: mocks.getProcessingConfig, })) +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.getDocumentTagDefinitions, +})) + vi.mock('@/lib/knowledge/orchestration/documents', () => ({ performUploadKnowledgeDocument: mocks.performSingleUpload, performUploadKnowledgeDocuments: mocks.performBulkUpload, @@ -524,6 +529,123 @@ describe('knowledge document application use cases', () => { ) }) + it('resolves typed tag-definition assignments into document tag slots', async () => { + mocks.getDocumentTagDefinitions.mockResolvedValueOnce([ + { + id: 'category-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'tag1', + displayName: 'Category', + fieldType: 'text', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'priority-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'number1', + displayName: 'Priority', + fieldType: 'number', + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'reviewed-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'boolean1', + displayName: 'Reviewed', + fieldType: 'boolean', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + tagValues: [ + { tagDefinitionId: 'category-tag', value: 'support' }, + { tagDefinitionId: 'priority-tag', value: 2 }, + { tagDefinitionId: 'reviewed-tag', value: false }, + ], + source: 'agent', + }, + }) + + expect(mocks.updateDocument).toHaveBeenCalledWith( + 'document-1', + { + filename: undefined, + enabled: undefined, + tag1: 'support', + number1: '2', + boolean1: 'false', + }, + expect.any(String) + ) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + tagDefinitionIds: ['category-tag', 'priority-tag', 'reviewed-tag'], + }), + }) + ) + }) + + it('rejects a tag value that does not match its definition type', async () => { + mocks.getDocumentTagDefinitions.mockResolvedValueOnce([ + { + id: 'priority-tag', + knowledgeBaseId: 'knowledge-1', + tagSlot: 'number1', + displayName: 'Priority', + fieldType: 'number', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + await expect( + updateKnowledgeDocument.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'shared-user', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: {}, + }, + input: { + knowledgeBaseId: 'knowledge-1', + documentId: 'document-1', + assertedWorkspaceId: 'workspace-1', + tagValues: [{ tagDefinitionId: 'priority-tag', value: 'urgent' }], + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Tag "Priority" expects a number value, but received "urgent"', + }) + + expect(mocks.updateDocument).not.toHaveBeenCalled() + }) + it('propagates document infrastructure failures without audit', async () => { const failure = new Error('storage ledger unavailable') mocks.createDocument.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/knowledge/application/documents.ts b/apps/sim/lib/knowledge/application/documents.ts index 11b1add79b2..cf5d1f987e4 100644 --- a/apps/sim/lib/knowledge/application/documents.ts +++ b/apps/sim/lib/knowledge/application/documents.ts @@ -33,7 +33,11 @@ import { resolveCanonicalActiveKnowledgeDocumentContext, } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' +import { + ALL_TAG_SLOTS, + type AllTagSlot, + MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, +} from '@/lib/knowledge/constants' import { bulkDocumentOperation, bulkDocumentOperationByFilter, @@ -56,6 +60,8 @@ import { performUploadKnowledgeDocuments, } from '@/lib/knowledge/orchestration/documents' import type { KnowledgeDocumentWriteSecretProvenance } from '@/lib/knowledge/secret-provenance' +import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' +import { validateTagValue } from '@/lib/knowledge/tags/utils' import { StorageService } from '@/lib/uploads' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' @@ -170,6 +176,7 @@ type BulkDeleteKnowledgeDocumentsContext = ActiveKnowledgeResourceBaseContext & export interface UpdateKnowledgeDocumentInput extends ReadKnowledgeDocumentInput { filename?: string enabled?: boolean + tagValues?: KnowledgeDocumentTagValueAssignment[] updates?: Parameters[1] markFailedDueToTimeout?: boolean retryProcessing?: boolean @@ -177,6 +184,68 @@ export interface UpdateKnowledgeDocumentInput extends ReadKnowledgeDocumentInput source?: string } +export interface KnowledgeDocumentTagValueAssignment { + tagDefinitionId: string + value: string | number | boolean | null +} + +type KnowledgeDocumentUpdates = Parameters[1] + +function isAllTagSlot(tagSlot: string): tagSlot is AllTagSlot { + return (ALL_TAG_SLOTS as readonly string[]).includes(tagSlot) +} + +async function resolveKnowledgeDocumentTagValueUpdates( + knowledgeBaseId: string, + tagValues: readonly KnowledgeDocumentTagValueAssignment[] +): Promise { + const definitions = await getDocumentTagDefinitions(knowledgeBaseId) + const definitionsById = new Map(definitions.map((definition) => [definition.id, definition])) + const seenDefinitionIds = new Set() + const updates: KnowledgeDocumentUpdates = {} + + for (const assignment of tagValues) { + if (seenDefinitionIds.has(assignment.tagDefinitionId)) { + throw new OrchestrationError( + 'validation', + `Duplicate tag definition ID: ${assignment.tagDefinitionId}` + ) + } + seenDefinitionIds.add(assignment.tagDefinitionId) + + const definition = definitionsById.get(assignment.tagDefinitionId) + if (!definition) { + throw new OrchestrationError( + 'validation', + `Tag definition ${assignment.tagDefinitionId} does not belong to this knowledge base` + ) + } + if (!isAllTagSlot(definition.tagSlot)) { + throw new Error(`Tag definition ${definition.id} has an unsupported slot`) + } + + if (assignment.value === null) { + updates[definition.tagSlot] = '' + continue + } + + const value = String(assignment.value).trim() + if (!value) { + throw new OrchestrationError( + 'validation', + `Tag "${definition.displayName}" requires a value; use null to clear it` + ) + } + const validationError = validateTagValue(definition.displayName, value, definition.fieldType) + if (validationError) { + throw new OrchestrationError('validation', validationError) + } + updates[definition.tagSlot] = value + } + + return updates +} + export interface BulkKnowledgeDocumentsInput extends UploadKnowledgeDocumentAdmissionInput { operation: 'enable' | 'disable' | 'delete' documentIds?: string[] @@ -790,7 +859,15 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ message: outcome.message, } } - const updates = input.updates ?? { filename: input.filename, enabled: input.enabled } + const updates: KnowledgeDocumentUpdates = input.updates + ? { ...input.updates } + : { filename: input.filename, enabled: input.enabled } + if (input.tagValues !== undefined) { + Object.assign( + updates, + await resolveKnowledgeDocumentTagValueUpdates(context.knowledgeBaseId, input.tagValues) + ) + } const updatedFields = Object.keys(updates).filter( (key) => updates[key as keyof typeof updates] !== undefined ) @@ -818,6 +895,9 @@ export const updateKnowledgeDocument = defineAuthorizedKnowledgeUseCase({ fileName: result.document.filename, updatedFields: result.updatedFields, ...(input.enabled !== undefined && { enabled: input.enabled }), + ...(input.tagValues !== undefined && { + tagDefinitionIds: input.tagValues.map((assignment) => assignment.tagDefinitionId), + }), }, } }, From 797076c1ea465aad13defc604b83bceecec98949 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 11:06:46 -0700 Subject: [PATCH 002/135] Checkpoint --- .../lib/copilot/generated/tool-catalog-v1.ts | 50 ++++++++++++++----- .../lib/copilot/generated/tool-schemas-v1.ts | 46 ++++++++++++----- .../copilot/request/tools/executor.test.ts | 6 ++- .../sim/lib/copilot/request/tools/executor.ts | 6 +-- bun.lock | 4 ++ 5 files changed, 84 insertions(+), 28 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 2248179ea8e..805bdf29f80 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -295,13 +295,25 @@ export const Browser: ToolCatalogEntry = { mode: 'async', parameters: { properties: { + sessionId: { + description: + 'Reusable session ID returned by an earlier browser call in this chat. Supply it only on a later user message that continues the same browsing objective, and at most once per user message.', + type: 'string', + }, task: { description: - 'The web task to complete, in plain language (include the target site/URL if known).', + "Optional brief scoping instruction that the conversation does not already convey. Do not restate the user's request.", + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this Browser Agent session's stable objective. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, - required: ['task'], + required: ['title'], type: 'object', }, subagentId: 'browser', @@ -1248,16 +1260,14 @@ export const Cp: ToolCatalogEntry = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -3268,6 +3278,26 @@ export const KnowledgeBase: ToolCatalogEntry = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3716,10 +3746,9 @@ export const Mkdir: ToolCatalogEntry = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -3742,16 +3771,14 @@ export const Mv: ToolCatalogEntry = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', @@ -4184,10 +4211,9 @@ export const Rm: ToolCatalogEntry = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { type: 'string', maxLength: 4096 }, + items: { type: 'string' }, }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 7248ec6dc7e..a7d92662bf8 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -39,13 +39,25 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { browser: { parameters: { properties: { + sessionId: { + description: + 'Reusable session ID returned by an earlier browser call in this chat. Supply it only on a later user message that continues the same browsing objective, and at most once per user message.', + type: 'string', + }, task: { description: - 'The web task to complete, in plain language (include the target site/URL if known).', + "Optional brief scoping instruction that the conversation does not already convey. Do not restate the user's request.", + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this Browser Agent session's stable objective. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, type: 'string', }, }, - required: ['task'], + required: ['title'], type: 'object', }, resultSchema: undefined, @@ -1112,18 +1124,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path under workflows/. An existing folder (or a path ending in "/") duplicates sources into it keeping their names; otherwise the last segment names the copy and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -3164,6 +3173,26 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3596,12 +3625,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -3620,18 +3647,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { destination: { type: 'string', - maxLength: 4096, description: 'Target path. A path ending in "/" (or naming an existing folder) moves sources into it keeping their names — always use the trailing "/" form when targeting a folder. Otherwise the last segment is the new name and the preceding segments are the target folder (created automatically when missing).', }, sources: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to move or rename, e.g. ["files/draft.md"]. All sources must share one category. Copy paths verbatim from glob/grep/read output.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { @@ -4067,12 +4091,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { paths: { type: 'array', - maxItems: 100, description: 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', items: { type: 'string', - maxLength: 4096, }, }, toolTitle: { diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 58b62d95f8e..b40964ab425 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -359,7 +359,11 @@ describe('executeToolAndReport provenance isolation', () => { const completion = await executeToolAndReport( toolCall.id, buildStreamingContext(toolCall), - { userId: 'user-1', workflowId: 'workflow-1' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), + }, { onEvent } ) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index abab3329518..01845830153 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -808,10 +808,10 @@ async function executeToolAndReportInner( return cancelledCompletion('Request aborted before tool result delivery') } - // Fire-and-forget: notify the copilot backend that the tool completed. - // IMPORTANT: We must NOT await this — the Go backend may block on the + // A newly generated API key is intentionally included only in this + // live/replay client event. Model-facing results and long-term chat records stay redacted. const clientEventOutput = - toolCall.name === GenerateApiKey.id && hasOutputValue(copilotResult) + toolCall.name === GenerateApiKey.id && modelSucceeded && hasOutputValue(copilotResult) ? copilotResult.output : terminalData const resultEvent: StreamEvent = { diff --git a/bun.lock b/bun.lock index efb1d9c2298..a6a5f598185 100644 --- a/bun.lock +++ b/bun.lock @@ -588,10 +588,14 @@ "packages/terminal-protocol": { "name": "@sim/terminal-protocol", "version": "0.1.0", + "dependencies": { + "@sim/utils": "workspace:*", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", "typescript": "^7.0.2", + "vitest": "^4.1.0", }, }, "packages/testing": { From a63c8fa35af1c42286e807576b312a2102aea51b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 11:31:31 -0700 Subject: [PATCH 003/135] dot fixes --- .../lib/copilot/async-runs/repository.test.ts | 24 +++ apps/sim/lib/copilot/constants.ts | 13 ++ .../copilot/request/handlers/handlers.test.ts | 123 ++++++++++++- apps/sim/lib/copilot/request/handlers/tool.ts | 88 ++++++--- .../tools/workflow-client-fallback.test.ts | 169 ++++++++++++++++++ .../request/tools/workflow-client-fallback.ts | 140 +++++++++++++++ apps/sim/lib/copilot/tool-executor/types.ts | 8 + .../tools/client/run-tool-execution.test.ts | 26 +++ .../tools/client/run-tool-execution.ts | 19 ++ .../tools/handlers/workflow/mutations.ts | 10 ++ .../run-workflow-from-copilot.test.ts | 65 +++++++ .../application/run-workflow-from-copilot.ts | 34 +++- 12 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts create mode 100644 apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index fcd9c01a4e7..e6098a8b851 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -11,6 +11,7 @@ import { completeAsyncToolCall, detachAsyncToolCall, getClaimedWorkflowExecutionId, + markAsyncToolRunning, recordToolPermissionDecision, releaseWorkflowToolExecutionClaim, replaceTerminalAsyncToolCallResult, @@ -162,6 +163,29 @@ describe('async tool repository single-row semantics', () => { await expect(claimWorkflowToolExecution('workflow-tool', 'execution-2')).resolves.toBeNull() }) + it('overwrites a workflow execution claim once the sim path starts running it', async () => { + // The server-side fallback claims `workflow:` and then immediately runs + // the tool, whose executor re-marks the row as running under 'sim-stream'. + // The claim value is therefore NOT durable identity — only its + // `claimedBy IS NULL` precondition is load-bearing, since that is what keeps + // a late browser locked out. Pinning this so nobody builds on reading it back. + dbChainMockFns.returning.mockResolvedValueOnce([ + { + toolCallId: 'workflow-tool', + status: 'running', + claimedBy: 'sim-stream', + }, + ]) + + const result = await markAsyncToolRunning('workflow-tool', 'sim-stream') + + expect(result).toMatchObject({ claimedBy: 'sim-stream' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ claimedBy: 'sim-stream' }) + ) + expect(getClaimedWorkflowExecutionId('sim-stream')).toBeUndefined() + }) + it('releases a matching pre-start workflow claim without changing its lifecycle status', async () => { dbChainMockFns.returning.mockResolvedValueOnce([ { diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 5102f753d51..1df1be6c40e 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -36,6 +36,19 @@ export const TOOL_WATCHDOG_RESUME_GRACE_MS = 30_000 /** Timeout for the client-side streaming response handler (60 min). */ export const STREAM_TIMEOUT_MS = 3_600_000 +/** + * How long a workflow tool call waits for a browser to pick it up before the + * server runs it itself. + * + * Workflow tools are client-routed, but the only thing that starts one is the + * mounted chat view — a call frame that arrives while the user is on a + * different chat is never dispatched by anyone, and the turn used to park for + * the full STREAM_TIMEOUT_MS. The real pickup path (stream frame -> execute + * POST -> claim) lands in ~1-3s, so 30s is an order of magnitude of headroom + * and cannot steal work from a live tab. + */ +export const COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS = 30_000 + /** SessionStorage key for persisting active stream metadata across page reloads. */ export const STREAM_STORAGE_KEY = 'copilot_active_stream' diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 9bcf12d9d7c..a6ace4a3acf 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -15,10 +15,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApprov }) ) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ +const { + upsertAsyncToolCall, + markAsyncToolRunning, + completeAsyncToolCall, + claimWorkflowToolExecution, +} = vi.hoisted(() => ({ upsertAsyncToolCall: vi.fn(), markAsyncToolRunning: vi.fn(), completeAsyncToolCall: vi.fn(), + claimWorkflowToolExecution: vi.fn().mockResolvedValue(null), })) const { waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion } = @@ -56,6 +62,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, + claimWorkflowToolExecution, })) vi.mock('@/lib/copilot/request/tools/client', () => ({ @@ -578,11 +585,13 @@ describe('sse-handlers tool lifecycle', () => { await sleep(0) await Promise.allSettled(context.pendingToolPromises.values()) + // The waiter always receives a signal now: the server fallback needs a + // handle to cancel its own wait if it ends up running the tool itself. expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith({ toolCallId: 'tool-background', workflowId: 'workflow-1', timeoutMs: 1000, - abortSignal: undefined, + abortSignal: expect.any(AbortSignal), registry: execContext.resolvedSecretTraceRegistry, }) expect(onEvent).toHaveBeenCalledWith( @@ -644,6 +653,116 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('runs a workflow tool server-side when no browser picks it up', async () => { + // Nobody claims it, the wait expires, and the server wins the claim. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-unclaimed' }) + executeTool.mockResolvedValueOnce({ success: true, output: { ran: 'on-server' } }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-unclaimed', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + // Regression guard: the wait sets the call to 'executing' before parking, + // and executeToolAndReport short-circuits anything already 'executing'. If + // the handoff stops resetting the status, executeTool is never reached and + // the workflow silently does not run. + expect(executeTool).toHaveBeenCalled() + expect(executeTool.mock.calls.at(-1)?.[0]).toBe('run_workflow') + // The claimed execution id must reach the handler so the run is attributable. + expect(executeTool.mock.calls.at(-1)?.[2]?.boundWorkflowExecutionId).toBeTruthy() + + const workflowResults = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event?.type === MothershipStreamV1EventType.tool && + event.payload?.toolCallId === 'tool-unclaimed' && + event.payload?.phase === MothershipStreamV1ToolPhase.result + ) + // Exactly one result, from the sim path — no client-flavored duplicate on top. + expect(workflowResults).toHaveLength(1) + expect(workflowResults[0].payload.executor).toBe(MothershipStreamV1ToolExecutor.sim) + }) + + it('claims and runs the same workflow when the call omits an explicit workflowId', async () => { + // The waiter resolves the target via resolveWorkflowToolTargetId(args, ctx) + // while the handler resolves it as params.workflowId || context.workflowId. + // If those two ever diverge, the fallback would claim one workflow and run + // another. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce({ toolCallId: 'tool-implicit-workflow' }) + executeTool.mockResolvedValueOnce({ success: true, output: {} }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-implicit-workflow', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(waitForWorkflowToolCompletion).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1' }) + ) + expect(executeTool.mock.calls.at(-1)?.[2]?.workflowId).toBe('workflow-1') + }) + + it('does not run a workflow tool server-side when a browser holds the claim', async () => { + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValueOnce(null) + executeTool.mockClear() + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-claimed-elsewhere', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(executeTool).not.toHaveBeenCalled() + }) + it('waits for the desktop client when a static VFS read is explicitly user-local', async () => { waitForClientToolCompletion.mockResolvedValueOnce({ status: 'success', diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 415e7475eed..d08f04b486d 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -2,9 +2,12 @@ import { isBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import type { + AsyncCompletionSignal, + AsyncTerminalCompletionSnapshot, +} from '@/lib/copilot/async-runs/lifecycle' import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' -import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' +import { COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, type MothershipStreamV1ToolCallDescriptor, @@ -24,10 +27,7 @@ import { } from '@/lib/copilot/request/session' import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { - waitForClientToolCompletion, - waitForWorkflowToolCompletion, -} from '@/lib/copilot/request/tools/client' +import { waitForClientToolCompletion } from '@/lib/copilot/request/tools/client' import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' import { @@ -35,6 +35,7 @@ import { TOOL_AWAITING_APPROVAL_STATUS, toolCallNeedsApproval, } from '@/lib/copilot/request/tools/permission' +import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' import type { ExecutionContext, OrchestratorOptions, @@ -671,9 +672,11 @@ async function dispatchToolExecution( ): Promise { const scopeLabel = scope === 'subagent' ? 'subagent ' : '' - const fireToolExecution = (): Promise => { + const fireToolExecution = ( + execContextOverride?: ExecutionContext + ): Promise => { return (async () => { - return executeToolAndReport(toolCallId, context, execContext, options) + return executeToolAndReport(toolCallId, context, execContextOverride ?? execContext, options) })().catch((err) => { logger.error(`Parallel ${scopeLabel}tool execution failed`, { toolCallId, @@ -754,23 +757,58 @@ async function dispatchToolExecution( ...(context.runId ? { [TraceAttr.RunId]: context.runId } : {}), }, async (span) => { - const completion = isWorkflowToolName(toolName) - ? await waitForWorkflowToolCompletion({ - toolCallId, - workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), - timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS, - abortSignal: options.abortSignal, - registry: execContext.resolvedSecretTraceRegistry, - }) - : await waitForClientToolCompletion({ - toolCallId, - runId: context.runId, - userId: execContext.userId, - timeoutMs, - abortSignal: options.abortSignal, - registry: execContext.resolvedSecretTraceRegistry, - }) - span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== undefined) + let completion: AsyncTerminalCompletionSnapshot | null + if (isWorkflowToolName(toolName)) { + const race = await raceWorkflowToolClientPickup({ + toolCallId, + workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), + timeoutMs: timeoutMs ?? STREAM_TIMEOUT_MS, + graceMs: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + runOnServer: (boundExecutionId) => { + // `executeToolAndReportInner` short-circuits a call that is + // already 'executing' — which is exactly what this wait set it to + // before parking. Hand it back the state it dispatches from. + toolCall.status = 'pending' + return fireToolExecution({ + ...execContext, + boundWorkflowExecutionId: boundExecutionId, + }) + }, + }) + + if (race.winner === 'sim') { + // `executeToolAndReport` already emitted its own `executor: sim` + // result and marked it seen, so the client-completion bookkeeping + // below must not run again on top of it. + span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.sim) + if (race.signal) { + span.setAttribute(TraceAttr.ToolOutcome, race.signal.status) + } + return ( + race.signal ?? { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool completion missing', + data: { error: 'Tool completion missing' }, + } + ) + } + completion = race.completion ?? null + } else { + completion = await waitForClientToolCompletion({ + toolCallId, + runId: context.runId, + userId: execContext.userId, + timeoutMs, + abortSignal: options.abortSignal, + registry: execContext.resolvedSecretTraceRegistry, + }) + } + span.setAttribute(TraceAttr.ToolExecutor, MothershipStreamV1ToolExecutor.client) + // Both waiters resolve `T | null`, never undefined — comparing against + // undefined made this a constant `true` and hid every timeout. + span.setAttribute(TraceAttr.ToolCompletionReceived, completion !== null) if (completion) { span.setAttribute(TraceAttr.ToolOutcome, completion.status) } diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts new file mode 100644 index 00000000000..328d62c6d39 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { waitForWorkflowToolCompletion, claimWorkflowToolExecution } = vi.hoisted(() => ({ + waitForWorkflowToolCompletion: vi.fn(), + claimWorkflowToolExecution: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForWorkflowToolCompletion, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + claimWorkflowToolExecution, +})) + +import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' + +const GRACE_MS = 30_000 +const TIMEOUT_MS = 3_600_000 + +/** Captures the abort signal the waiter was handed so tests can assert teardown. */ +let waiterSignals: (AbortSignal | undefined)[] = [] + +/** Models the real waiter: pends until aborted, then resolves null. */ +function pendingUntilAborted() { + waitForWorkflowToolCompletion.mockImplementation(({ abortSignal }) => { + waiterSignals.push(abortSignal) + return new Promise((resolve) => { + if (abortSignal?.aborted) { + resolve(null) + return + } + abortSignal?.addEventListener('abort', () => resolve(null), { once: true }) + }) + }) +} + +function baseParams(overrides: Record = {}) { + return { + toolCallId: 'tool-1', + workflowId: 'workflow-1', + timeoutMs: TIMEOUT_MS, + graceMs: GRACE_MS, + runOnServer: vi.fn().mockResolvedValue({ status: 'success' }), + ...overrides, + } +} + +describe('raceWorkflowToolClientPickup', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + waiterSignals = [] + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('lets the client win without ever attempting a claim', async () => { + waitForWorkflowToolCompletion.mockResolvedValue({ status: 'success', data: { ok: true } }) + const params = baseParams() + + const outcome = await raceWorkflowToolClientPickup(params as never) + + expect(outcome.winner).toBe('client') + expect(outcome.completion).toEqual({ status: 'success', data: { ok: true } }) + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + expect(params.runOnServer).not.toHaveBeenCalled() + }) + + it('runs the tool server-side when the grace elapses and the claim is won', async () => { + pendingUntilAborted() + claimWorkflowToolExecution.mockResolvedValue({ toolCallId: 'tool-1' }) + const params = baseParams() + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(GRACE_MS) + const outcome = await promise + + expect(outcome.winner).toBe('sim') + expect(outcome.signal).toEqual({ status: 'success' }) + expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) + expect(params.runOnServer).toHaveBeenCalledTimes(1) + // The claimed id is what the server run must bind to. + expect(params.runOnServer).toHaveBeenCalledWith(outcome.boundExecutionId) + expect(outcome.boundExecutionId).toBeTruthy() + // The client waiter must be torn down before the server runs, or the sim + // path's own confirmation would wake it and emit a duplicate result. + expect(waiterSignals.at(0)?.aborted).toBe(true) + }) + + it('keeps waiting on the browser when the claim is lost', async () => { + // The waiter stays pending until the "browser" reports, so we can assert the + // helper went back to waiting on the same promise rather than running. + let reportFromBrowser!: (value: unknown) => void + waitForWorkflowToolCompletion.mockImplementation(({ abortSignal }) => { + waiterSignals.push(abortSignal) + return new Promise((resolve) => { + reportFromBrowser = resolve + }) + }) + claimWorkflowToolExecution.mockResolvedValue(null) + const params = baseParams() + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(GRACE_MS) + + expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) + expect(params.runOnServer).not.toHaveBeenCalled() + // The waiter must NOT have been torn down — a browser owns this call. + expect(waiterSignals.at(0)?.aborted).toBe(false) + + reportFromBrowser({ status: 'success', data: { ranInBrowser: true } }) + const outcome = await promise + + expect(outcome.winner).toBe('client') + expect(outcome.completion).toEqual({ status: 'success', data: { ranInBrowser: true } }) + expect(waitForWorkflowToolCompletion).toHaveBeenCalledTimes(1) + }) + + it('never claims work on a turn the user already stopped', async () => { + pendingUntilAborted() + const abortController = new AbortController() + const params = baseParams({ abortSignal: abortController.signal }) + + const promise = raceWorkflowToolClientPickup(params as never) + abortController.abort() + await vi.advanceTimersByTimeAsync(GRACE_MS) + const outcome = await promise + + expect(outcome.winner).toBe('client') + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + expect(params.runOnServer).not.toHaveBeenCalled() + }) + + it('still falls back when the wait expires before the grace window', async () => { + // A caller-supplied timeout shorter than the grace makes the waiter resolve + // null first; that is an expired wait, not a missing completion. + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockResolvedValue({ toolCallId: 'tool-1' }) + const params = baseParams({ timeoutMs: 1_000 }) + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(1_000) + const outcome = await promise + + expect(outcome.winner).toBe('sim') + expect(params.runOnServer).toHaveBeenCalledTimes(1) + }) + + it('keeps waiting on the browser when the claim itself errors', async () => { + waitForWorkflowToolCompletion.mockResolvedValue(null) + claimWorkflowToolExecution.mockRejectedValue(new Error('db down')) + const params = baseParams({ timeoutMs: 1_000 }) + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(1_000) + const outcome = await promise + + // Losing the claim to an error must never become a second execution. + expect(outcome.winner).toBe('client') + expect(params.runOnServer).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts new file mode 100644 index 00000000000..5e63209ef6d --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts @@ -0,0 +1,140 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import type { + AsyncCompletionSignal, + AsyncTerminalCompletionSnapshot, +} from '@/lib/copilot/async-runs/lifecycle' +import { claimWorkflowToolExecution } from '@/lib/copilot/async-runs/repository' +import { waitForWorkflowToolCompletion } from '@/lib/copilot/request/tools/client' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('CopilotWorkflowClientFallback') + +/** Which side actually ran the workflow for this tool call. */ +export type WorkflowToolWinner = 'client' | 'sim' + +export interface WorkflowToolRaceOutcome { + winner: WorkflowToolWinner + /** Set when `winner === 'client'`; null means the client wait timed out. */ + completion?: AsyncTerminalCompletionSnapshot | null + /** Set when `winner === 'sim'`. */ + signal?: AsyncCompletionSignal + /** The execution id the server claimed, when it won. */ + boundExecutionId?: string +} + +interface RaceWorkflowToolClientPickupParams { + toolCallId: string + workflowId?: string + timeoutMs: number + graceMs: number + abortSignal?: AbortSignal + registry?: ResolvedSecretTraceRegistry + /** Runs the tool in-process; only invoked after the execution claim is won. */ + runOnServer: (boundExecutionId: string) => Promise +} + +/** + * Wait for a browser to run a workflow tool call, and run it here if none does. + * + * Workflow tools are client-routed, but the only thing that dispatches one is + * the mounted chat view. A call frame that arrives while the user sits on a + * different chat is picked up by nobody, and the turn used to park for the full + * `timeoutMs` (an hour) before failing. + * + * After `graceMs` with no result, this competes for the same single-winner + * execution claim that `/api/workflows/[id]/execute` takes on the browser's + * behalf. Losing the claim means a browser really is running it, so we go back + * to waiting; winning it means nobody was there, so we run it in-process. + * Because both sides contend on `claimedBy IS NULL`, the workflow can never run + * twice — a browser arriving late gets a 409 it already treats as benign. + */ +export async function raceWorkflowToolClientPickup( + params: RaceWorkflowToolClientPickupParams +): Promise { + const { toolCallId, workflowId, timeoutMs, graceMs, abortSignal, registry, runOnServer } = params + + // Cancels only OUR client waiter once the server takes over, without + // disturbing the caller's turn-level abort signal. + const cancelClientWait = new AbortController() + const clientWaitSignal = abortSignal + ? AbortSignal.any([abortSignal, cancelClientWait.signal]) + : cancelClientWait.signal + + // Exactly one waiter for the whole race — a second one would double-consume + // the confirmation and emit a duplicate tool result. + const clientWait = waitForWorkflowToolCompletion({ + toolCallId, + workflowId, + timeoutMs, + abortSignal: clientWaitSignal, + registry, + }) + + // A caller-supplied timeout shorter than the grace must win, or the grace + // would outlive the wait it is supposed to bound. + const effectiveGraceMs = Math.min(graceMs, timeoutMs) + + const first = await Promise.race([ + clientWait.then((completion) => ({ kind: 'client' as const, completion })), + sleep(effectiveGraceMs).then(() => ({ kind: 'grace' as const })), + ]) + + // A non-null client result inside the grace window is the normal path. + // A null one means the wait itself already expired (timeoutMs <= graceMs), so + // fall through and try the claim rather than reporting a missing completion. + if (first.kind === 'client' && first.completion !== null) { + return { winner: 'client', completion: first.completion } + } + + // Never claim work on a turn the user already stopped. + if (abortSignal?.aborted) { + return { winner: 'client', completion: await clientWait } + } + + const boundExecutionId = generateId() + // The repository returns `row ?? null`, but with no `noUncheckedIndexedAccess` + // the destructured row types as non-optional and the null collapses away. + // It is genuinely null when the claim is lost, so widen it back — the same + // reality `/api/workflows/[id]/execute` leans on for its `if (!boundToolCall)`. + let claimed: Awaited> | null = null + try { + claimed = await claimWorkflowToolExecution(toolCallId, boundExecutionId) + } catch (error) { + // Losing the claim to an error is not a reason to run the workflow twice; + // fall back to waiting on the browser exactly as before. + logger.warn('Failed to claim workflow tool execution for server fallback', { + toolCallId, + workflowId, + error: toError(error).message, + }) + return { winner: 'client', completion: await clientWait } + } + + if (!claimed) { + logger.info('Workflow tool already claimed by a client; continuing to wait', { + toolCallId, + workflowId, + }) + return { winner: 'client', completion: await clientWait } + } + + logger.info('No client picked up workflow tool within grace; running it server-side', { + toolCallId, + workflowId, + boundExecutionId, + graceMs: effectiveGraceMs, + }) + + // Tear the waiter down BEFORE running in-process. The server path publishes + // its own terminal confirmation on the same channel this waiter subscribes + // to, so a live waiter would resolve with our own result and emit a second, + // client-flavored tool result on top of it. Awaiting is what guarantees the + // subscription is gone, not just signalled. + cancelClientWait.abort() + await clientWait + + return { winner: 'sim', signal: await runOnServer(boundExecutionId), boundExecutionId } +} diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 17c233e2550..789d48df5f0 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -13,6 +13,14 @@ export interface ToolExecutionContext { runId?: string /** Stable identity of the individual tool call being executed. */ toolCallId?: string + /** + * Workflow execution id this tool call is already bound to, set only by the + * copilot request handler when it wins the workflow-tool execution claim and + * runs the tool server-side instead of waiting for a browser. Distinct from + * `executionId`, which is the copilot run's own identity and is re-emitted + * into the principal by `requireTrustedCopilotExecutionContext`. + */ + boundWorkflowExecutionId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean /** Server-owned base image selected from the fixed Go route for this turn. */ diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index d0299ac419c..7b4817d0b89 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -451,6 +451,32 @@ describe('run tool execution cancellation', () => { ) }) + it('drops a duplicate async launch without confirming or surfacing an error', async () => { + // The server fallback (or another tab) already claimed this tool call. + // Reporting an error here would overwrite a run that is in flight. + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 409, + json: vi.fn().mockResolvedValue({ + error: 'Copilot workflow tool is already bound to another execution', + code: 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', + }), + }) + vi.stubGlobal('fetch', fetchMock) + + executeRunToolOnClient('tool-async-duplicate', 'run_workflow', { + workflowId: 'wf-1', + async: true, + }) + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + // Only the execute attempt — never a /api/copilot/confirm report. + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][0]).toBe('/api/workflows/wf-1/execute') + expect(saveExecutionPointer).not.toHaveBeenCalled() + }) + it('drops a duplicate client runner without confirming or surfacing an error', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 4e7e6e2340d..6ccb62f1f96 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -69,6 +69,13 @@ function resolveTriggerBlockId(params: Record): string | undefi : undefined } +/** The execute endpoint's "this tool call is already bound to another run" body. */ +function isWorkflowExecutionConflict(responseBody: unknown): boolean { + return ( + isPlainRecord(responseBody) && responseBody.code === COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE + ) +} + async function enqueueAsyncWorkflowRun( toolCallId: string, workflowId: string, @@ -120,6 +127,18 @@ async function enqueueAsyncWorkflowRun( acceptanceIsAmbiguous = isPlainRecord(responseBody) && responseBody.code === 'ASYNC_ENQUEUE_AMBIGUOUS' + // Someone else — another tab, or the server's own fallback — already owns + // this tool call. Stay silent so the winner reports the result; reporting + // an error here would overwrite a run that is happily in flight. Mirrors + // the streamed path's handling of the same conflict. + if (response.status === 409 && isWorkflowExecutionConflict(responseBody)) { + logger.info('[RunTool] Ignoring duplicate async workflow launch', { + toolCallId, + workflowId, + }) + return + } + if (!response.ok && !acceptanceIsAmbiguous) { const responseError = deploymentError?.message ?? diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 0769954cad9..ee407450105 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -102,6 +102,16 @@ function copilotRunLifecycle(context: ExecutionContext) { billingAttribution: context.billingAttribution, resolvedSecretTraceRegistry: context.resolvedSecretTraceRegistry, abortSignal: context.abortSignal, + // Present only when the request handler already claimed an execution id for + // this tool call because it is running the workflow server-side. + ...(context.boundWorkflowExecutionId && context.toolCallId + ? { + boundExecution: { + executionId: context.boundWorkflowExecutionId, + copilotToolCallId: context.toolCallId, + }, + } + : {}), } } diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index dcec0fdb8a8..90d3bf6a3bf 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -148,6 +148,71 @@ describe('Copilot workflow run application commands', () => { ) }) + it('runs under a caller-claimed execution id and stamps its copilot correlation', async () => { + // Set when the request handler wins the workflow-tool claim and runs the + // tool server-side. The claimed id must BE the child execution id, and the + // log row must carry the tool-call correlation, or a server-run tool call + // is unattributable where a browser-run one is not. + await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle: { + ...lifecycle, + boundExecution: { + executionId: 'claimed-execution-1', + copilotToolCallId: 'tool-call-1', + }, + }, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(mocks.admission).toHaveBeenCalledWith( + { userId: 'user-1', billingAttribution: undefined }, + 'workspace-1', + 'claimed-execution-1' + ) + expect(mocks.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ id: 'workflow-1' }), + 'request-1', + { source: 'mock' }, + 'user-1', + expect.objectContaining({ + trustedExecutionCorrelation: { + executionId: 'claimed-execution-1', + requestId: 'request-1', + source: 'workflow', + workflowId: 'workflow-1', + triggerType: 'copilot', + copilotToolCallId: 'tool-call-1', + }, + }), + 'claimed-execution-1' + ) + }) + + it('does not stamp a correlation for an ordinary browser-routed run', async () => { + await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + + expect(mocks.executeWorkflow.mock.calls.at(-1)?.[4]).not.toHaveProperty( + 'trustedExecutionCorrelation' + ) + }) + it('rechecks current permission before loading execution state', async () => { mocks.permission.mockResolvedValueOnce(null) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 6a8b3527c4e..705999129cd 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -34,6 +34,20 @@ export interface CopilotWorkflowRunLifecycle { billingAttribution?: BillingAttributionSnapshot resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry abortSignal?: AbortSignal + /** + * Execution identity the caller already claimed for this Copilot tool call. + * + * Set only when the copilot request handler runs a workflow tool server-side + * because no browser picked it up. Using the claimed id as the child + * execution id — and stamping the matching trusted correlation — keeps a + * server-run tool call as attributable in `workflow_execution_logs` as a + * browser-routed one, which `/api/workflows/[id]/execute` does for its own + * claim at the equivalent point. + */ + boundExecution?: { + executionId: string + copilotToolCallId: string + } } interface BaseCopilotRunInput { @@ -207,7 +221,11 @@ async function executeCopilotRun(params: { } }): Promise { const actorUserId = requirePrincipalSubjectUserId(params.principal) - const childExecutionId = generateId() + const boundExecution = params.input.lifecycle.boundExecution + // Reuse the caller's already-claimed execution id so the claim and the log + // row describe the same run; otherwise mint our own as before. + const childExecutionId = boundExecution?.executionId ?? generateId() + const requestId = generateRequestId() const admission = await prepareWorkflowExecutionAdmission( { userId: actorUserId, @@ -229,7 +247,7 @@ async function executeCopilotRun(params: { workspaceId: params.context.workspaceId, variables: params.context.workflow.variables || {}, }, - generateRequestId(), + requestId, params.executionInput, actorUserId, { @@ -244,6 +262,18 @@ async function executeCopilotRun(params: { ...(trustedInitialResolvedSecretTraceProvenance ? { trustedInitialResolvedSecretTraceProvenance } : {}), + ...(boundExecution + ? { + trustedExecutionCorrelation: { + executionId: childExecutionId, + requestId, + source: 'workflow' as const, + workflowId: params.context.workflowId, + triggerType: 'copilot', + copilotToolCallId: boundExecution.copilotToolCallId, + }, + } + : {}), }, childExecutionId ) From b5bae34edf2b5e4272c44e1fa761c8e78019ddda Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 15:45:09 -0700 Subject: [PATCH 004/135] Make async tool resume delivery recoverable --- .../[id]/execute/route.async.test.ts | 38 +++- .../app/api/workflows/[id]/execute/route.ts | 60 ++--- .../utils/workflow-execution-utils.ts | 5 +- apps/sim/lib/copilot/generated/metrics-v1.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 15 +- .../lib/copilot/generated/tool-schemas-v1.ts | 15 +- .../generated/trace-attribute-values-v1.ts | 10 + .../copilot/generated/trace-attributes-v1.ts | 2 + .../copilot/request/handlers/handlers.test.ts | 132 +++++++++++ apps/sim/lib/copilot/request/handlers/tool.ts | 55 +++++ .../sim/lib/copilot/request/handlers/types.ts | 32 ++- .../lifecycle/resume-leg-context.test.ts | 35 +++ .../lib/copilot/request/lifecycle/run.test.ts | 154 +++++++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 215 +++++++++++++----- apps/sim/lib/copilot/request/metrics.ts | 15 ++ .../tools/workflow-client-fallback.test.ts | 16 +- .../request/tools/workflow-client-fallback.ts | 3 + .../tools/client/run-tool-execution.test.ts | 32 +++ .../tools/client/run-tool-execution.ts | 13 +- .../lib/copilot/tools/workflow-tools.test.ts | 86 +++++++ apps/sim/lib/copilot/tools/workflow-tools.ts | 101 ++++++++ 21 files changed, 910 insertions(+), 126 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/workflow-tools.test.ts diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 4adac12ff2a..20bf4ff7e5d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -852,8 +852,13 @@ describe('workflow execute async route', () => { status: 'pending', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_AWAITING_APPROVAL', ], [ + // A finished call is a benign duplicate, not a defect: some other runner + // already owns this tool call, so it reports the same conflict the + // execution claim does and the client stays silent. 'terminal tool row', { toolCallId: 'copilot-tool-1', @@ -863,6 +868,8 @@ describe('workflow execute async route', () => { status: 'completed', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 409, + 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', ], [ 'different workflow target', @@ -874,6 +881,8 @@ describe('workflow execute async route', () => { status: 'running', }, { id: 'copilot-run-1', userId: 'session-user-1', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH', ], [ 'different execution actor', @@ -885,19 +894,28 @@ describe('workflow execute async route', () => { status: 'running', }, { id: 'copilot-run-1', userId: 'other-user', workflowId: 'workflow-1' }, + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_FOREIGN_OWNER', ], - ])('rejects a Copilot binding owned by a %s', async (_caseName, toolCall, run) => { - mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) - mockGetRunSegment.mockResolvedValueOnce(run) + ['missing tool row', null, null, 404, 'COPILOT_WORKFLOW_TOOL_BINDING_UNKNOWN'], + ])( + 'rejects a Copilot binding owned by a %s', + async (_caseName, toolCall, run, expectedStatus, expectedCode) => { + mockGetAsyncToolCall.mockResolvedValueOnce(toolCall) + mockGetRunSegment.mockResolvedValueOnce(run) - const response = await POST(createBoundCopilotExecutionRequest(), { - params: Promise.resolve({ id: 'workflow-1' }), - }) + const response = await POST(createBoundCopilotExecutionRequest(), { + params: Promise.resolve({ id: 'workflow-1' }), + }) - expect(response.status).toBe(403) - expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() - expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() - }) + expect(response.status).toBe(expectedStatus) + // The reason must be machine-readable — an opaque 403 is what stopped the + // client telling a benign duplicate from a real failure. + await expect(response.json()).resolves.toMatchObject({ code: expectedCode }) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + expect(loggingSessionMockFns.mockSetTrustedExecutionCorrelation).not.toHaveBeenCalled() + } + ) it('rejects Copilot workflow bindings outside the interactive SSE surface', async () => { const response = await POST(createBoundCopilotExecutionRequest({ stream: false }), { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 25704a10308..ba44c86f0c7 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -21,7 +21,6 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { isWorkflowToolExecutionClaimable } from '@/lib/copilot/async-runs/lifecycle' import { claimWorkflowToolExecution, getAsyncToolCall, @@ -29,10 +28,12 @@ import { releaseWorkflowToolExecutionClaim, } from '@/lib/copilot/async-runs/repository' import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { ASYNC_WORKFLOW_DEPLOYMENT_ERRORS, - isWorkflowToolName, - resolveWorkflowToolTargetId, + type CopilotWorkflowToolBindingResult, + classifyWorkflowToolBinding, } from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { @@ -168,25 +169,19 @@ const SERVER_EXECUTION_ID_CLAIM_ATTEMPTS = 3 export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -async function isValidCopilotWorkflowToolBinding(params: { +async function resolveCopilotWorkflowToolBinding(params: { toolCallId: string userId: string workflowId: string -}): Promise { +}): Promise { const toolCall = await getAsyncToolCall(params.toolCallId) - if ( - !toolCall || - !isWorkflowToolName(toolCall.toolName) || - !isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision) - ) { - return false - } - - const run = await getRunSegment(toolCall.runId) - return ( - run?.userId === params.userId && - resolveWorkflowToolTargetId(toolCall.args, run.workflowId) === params.workflowId - ) + const run = toolCall ? await getRunSegment(toolCall.runId) : null + return classifyWorkflowToolBinding({ + toolCall, + run, + userId: params.userId, + workflowId: params.workflowId, + }) } function createExecutionJsonResponse( @@ -1019,18 +1014,29 @@ async function handleExecutePost( ) } - if ( - copilotToolCallId && - !(await isValidCopilotWorkflowToolBinding({ + if (copilotToolCallId) { + const binding = await resolveCopilotWorkflowToolBinding({ toolCallId: copilotToolCallId, userId, workflowId, - })) - ) { - return NextResponse.json( - { error: 'Copilot workflow tool binding was not found' }, - { status: 403 } - ) + }) + if (!binding.ok) { + // This rejection happens before any LoggingSession exists, so it leaves + // no execution log and no workflow span — log the reason or it is + // invisible everywhere except the browser console. + // This rejection happens before a LoggingSession or any workflow span + // exists, so the counter is the only place it becomes visible. + recordDegraded(CopilotDegradedReason.BindingRejected) + reqLogger.warn('Rejected Copilot workflow tool execution', { + copilotToolCallId, + workflowId, + reason: binding.rejection.code, + }) + return NextResponse.json( + { error: binding.rejection.message, code: binding.rejection.code }, + { status: binding.rejection.statusCode } + ) + } } if (inputFromExecutionId) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 3f888b2e19c..4dc59961db6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -1070,7 +1070,10 @@ export async function executeWorkflowWithFullLogging( error: errorMessage, httpStatus: response.status, }) - throw new Error(errorMessage) + // Keep the status and code on the thrown error. Downgrading to a bare Error + // discarded both, so callers could not tell a Copilot binding rejection from + // any other 4xx — and the reason never reached the agent that could fix it. + throw new ExecutionStreamHttpError(errorMessage, response.status, errorCode) } if (!response.body) { diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/copilot/generated/metrics-v1.ts index 06c74839c47..b379803f21f 100644 --- a/apps/sim/lib/copilot/generated/metrics-v1.ts +++ b/apps/sim/lib/copilot/generated/metrics-v1.ts @@ -19,6 +19,7 @@ export const Metric = { CopilotCacheWrite: 'copilot.cache.write', CopilotChatBlobBytes: 'copilot.chat.blob.bytes', CopilotChatBlobCount: 'copilot.chat.blob.count', + CopilotDegradedCount: 'copilot.degraded.count', CopilotFileReadDuration: 'copilot.file.read.duration', CopilotFileReadSize: 'copilot.file.read.size', CopilotMessagesSerializeDuration: 'copilot.messages.serialize.duration', @@ -48,6 +49,7 @@ export const MetricValues: readonly MetricValue[] = [ 'copilot.cache.write', 'copilot.chat.blob.bytes', 'copilot.chat.blob.count', + 'copilot.degraded.count', 'copilot.file.read.duration', 'copilot.file.read.size', 'copilot.messages.serialize.duration', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 805bdf29f80..91fe4c4c2e9 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4268,14 +4268,14 @@ export const RunBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['blockId'], + required: ['workflowId', 'blockId'], }, clientExecutable: true, } @@ -4396,14 +4396,14 @@ export const RunFromBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['startBlockId'], + required: ['workflowId', 'startBlockId'], }, clientExecutable: true, } @@ -4444,7 +4444,7 @@ export const RunWorkflow: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4452,6 +4452,7 @@ export const RunWorkflow: ToolCatalogEntry = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, + required: ['workflowId'], }, clientExecutable: true, requiresApproval: true, @@ -4492,7 +4493,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4500,7 +4501,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['stopAfterBlockId'], + required: ['workflowId', 'stopAfterBlockId'], }, clientExecutable: true, requiresApproval: true, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a7d92662bf8..ccc7a598c03 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4145,14 +4145,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['blockId'], + required: ['workflowId', 'blockId'], }, resultSchema: undefined, }, @@ -4270,14 +4270,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['startBlockId'], + required: ['workflowId', 'startBlockId'], }, resultSchema: undefined, }, @@ -4313,7 +4313,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4321,6 +4321,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, + required: ['workflowId'], }, resultSchema: undefined, }, @@ -4355,7 +4356,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { workflowId: { type: 'string', description: - 'Optional workflow ID to run. If not provided, uses the current workflow in context.', + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', }, workflow_input: { type: 'object', @@ -4363,7 +4364,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['stopAfterBlockId'], + required: ['workflowId', 'stopAfterBlockId'], }, resultSchema: undefined, }, diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts index 1fad4c11323..916ecc88569 100644 --- a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts @@ -117,6 +117,16 @@ export const CopilotConfirmOutcome = { export type CopilotConfirmOutcomeKey = keyof typeof CopilotConfirmOutcome export type CopilotConfirmOutcomeValue = (typeof CopilotConfirmOutcome)[CopilotConfirmOutcomeKey] +export const CopilotDegradedReason = { + BindingRejected: 'binding_rejected', + ClientPickupTimeout: 'client_pickup_timeout', + MissingToolResult: 'missing_tool_result', + StreamDeadBeforeDispatch: 'stream_dead_before_dispatch', +} as const + +export type CopilotDegradedReasonKey = keyof typeof CopilotDegradedReason +export type CopilotDegradedReasonValue = (typeof CopilotDegradedReason)[CopilotDegradedReasonKey] + export const CopilotFinalizeOutcome = { Aborted: 'aborted', Error: 'error', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 6db17a6329d..5a026a33c3a 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -189,6 +189,7 @@ export const TraceAttr = { CopilotCommandsCount: 'copilot.commands.count', CopilotConfirmOutcome: 'copilot.confirm.outcome', CopilotContextsCount: 'copilot.contexts.count', + CopilotDegradedReason: 'copilot.degraded.reason', CopilotExecutionId: 'copilot.execution.id', CopilotFileAttachmentsCount: 'copilot.file_attachments.count', CopilotFinalizeOutcome: 'copilot.finalize.outcome', @@ -833,6 +834,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.commands.count', 'copilot.confirm.outcome', 'copilot.contexts.count', + 'copilot.degraded.reason', 'copilot.execution.id', 'copilot.file_attachments.count', 'copilot.finalize.outcome', diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index a6ace4a3acf..32562b8293b 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -143,6 +143,57 @@ describe('sse-handlers tool lifecycle', () => { } }) + it('pins the workflow target into the args it persists and forwards', async () => { + // The browser resolved its own target from the open tab while the server + // resolved the run's workflow; in a workspace chat those disagreed and every + // omitted-argument call was rejected. One stamped field ends that. + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'run-workflow-1', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}, execContext) + + // Forwarded frame — this is what the browser POSTs back with. + expect((event.payload as { arguments?: Record }).arguments).toEqual({ + workflowId: 'workflow-1', + }) + expect(upsertAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ toolCallId: 'run-workflow-1', args: { workflowId: 'workflow-1' } }) + ) + }) + + it('leaves an explicit workflow target untouched', async () => { + isSimExecuted.mockReturnValue(false) + context.runId = 'run-1' + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'run-workflow-2', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-explicit' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent + + await prePersistClientExecutableToolCall(event, context, {}, execContext) + + expect(upsertAsyncToolCall).toHaveBeenCalledWith( + expect.objectContaining({ args: { workflowId: 'workflow-explicit' } }) + ) + }) + it('pre-persists browser tools as pending for the desktop authorization claim', async () => { isSimExecuted.mockReturnValue(false) context.runId = 'run-1' @@ -735,6 +786,53 @@ describe('sse-handlers tool lifecycle', () => { expect(executeTool.mock.calls.at(-1)?.[2]?.workflowId).toBe('workflow-1') }) + it('refuses a workflow tool call with no resolvable workflow target', async () => { + // A workspace chat has no run-scoped workflow, so an omitted workflowId + // cannot be resolved by anyone on this side. Dispatching would only buy a + // rejection the model cannot read, so fail with something it can act on. + const workspaceExecContext = { ...execContext, workflowId: '' } + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-unbound-workflow', + toolName: 'run_workflow', + arguments: {}, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + workspaceExecContext, + { onEvent, interactive: true, timeout: 1000 } + ) + + await Promise.allSettled(context.pendingToolPromises.values()) + + // Never handed to a browser, and never claimed server-side. + expect(waitForWorkflowToolCompletion).not.toHaveBeenCalled() + expect(claimWorkflowToolExecution).not.toHaveBeenCalled() + + const results = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event?.type === MothershipStreamV1EventType.tool && + event.payload?.toolCallId === 'tool-unbound-workflow' && + event.payload?.phase === MothershipStreamV1ToolPhase.result + ) + expect(results).toHaveLength(1) + expect(results[0].payload.status).toBe(MothershipStreamV1ToolOutcome.error) + // The message has to name the fix, or the model just retries identically. + expect(results[0].payload.output?.error).toContain('workflowId') + expect(context.toolCalls.get('tool-unbound-workflow')?.status).toBe( + MothershipStreamV1ToolOutcome.error + ) + }) + it('does not run a workflow tool server-side when a browser holds the claim', async () => { waitForWorkflowToolCompletion.mockResolvedValue(null) claimWorkflowToolExecution.mockResolvedValueOnce(null) @@ -1488,6 +1586,40 @@ describe('sse-handlers tool lifecycle', () => { expect(context.pendingToolPromises.has('tool-inflight')).toBe(false) }) + it('leaves a complete terminal state when a tool is cancelled before dispatch', async () => { + // A tool cancelled because its stream was already aborted used to get a + // status but no `result`. The subagent join requires one, so that single + // half-finished tool call was turned into a thrown "missing result" that + // killed the entire turn and blamed an unrelated tool. + context.wasAborted = true + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-stream-dead', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: false, timeout: 1000 } + ) + + await sleep(0) + + const toolCall = context.toolCalls.get('tool-stream-dead') + expect(executeTool).not.toHaveBeenCalled() + expect(toolCall?.status).toBe(MothershipStreamV1ToolOutcome.cancelled) + // The part that was missing: a terminal tool must also be complete. + expect(toolCall?.result).toEqual({ success: false }) + expect(toolCall?.error).toBeTruthy() + }) + it('still executes the tool when async row upsert fails', async () => { upsertAsyncToolCall.mockRejectedValueOnce(new Error('db down')) executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index d08f04b486d..c7e340b0133 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -226,6 +226,22 @@ export async function prePersistClientExecutableToolCall( if (!context.runId) return + // Pin the workflow target into the arguments before they are sealed, persisted, + // and forwarded, so the row, the browser, and the completion waiter all read one + // explicit field. + // + // They used to disagree: the server resolved `args.workflowId ?? run.workflowId` + // while the browser resolved `args.workflowId ?? activeWorkflowId`. In a + // workspace chat `copilot_runs.workflow_id` is NULL, so every call that omitted + // the (optional) argument resolved to nothing server-side and to the open tab + // client-side — a guaranteed rejection at the execute endpoint. + if (isWorkflowToolName(data.toolName)) { + const targetWorkflowId = resolveWorkflowToolTargetId(data.arguments, execContext?.workflowId) + if (targetWorkflowId) { + data.arguments = { ...(data.arguments ?? {}), workflowId: targetWorkflowId } + } + } + let sealedContext: Awaited> | undefined if (execContext?.resolvedSecretTraceRegistry) { try { @@ -691,6 +707,39 @@ async function dispatchToolExecution( }) } + /** + * Refuse a workflow tool call whose target this side cannot name. + * + * The execute endpoint validates the run against the tool call's bound + * workflow, so dispatching an unbound call only buys a rejection the model + * cannot interpret. Failing here instead tells it exactly what to send back, + * and never guesses a workflow on the user's behalf. + */ + const refuseUnboundWorkflowTool = async (): Promise => { + const error = `${toolName} requires an explicit workflowId. This chat is not scoped to a workflow, so there is no current workflow to fall back to — pass the id of the workflow to run.` + logger.warn('Refusing workflow tool call with no resolvable workflow target', { + toolCallId, + toolName, + }) + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.error, + output: { error }, + error, + }) + markToolResultSeen(toolCallId) + await emitSyntheticToolResult( + toolCallId, + toolCall.name, + { + status: MothershipStreamV1ToolOutcome.error, + message: error, + data: { error }, + }, + options + ) + return { status: MothershipStreamV1ToolOutcome.error, message: error, data: { error } } + } + // Returns the promise instead of registering it, so the permission gate can // wrap the whole thing in one pending promise that stays unsettled until the // tool has actually run. Null means nothing was dispatched. @@ -708,6 +757,12 @@ async function dispatchToolExecution( if (abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) return null return fireToolExecution() } + if ( + delegateWorkflowRunToClient && + !resolveWorkflowToolTargetId(args, execContext.workflowId) + ) { + return refuseUnboundWorkflowTool() + } return waitForClientExecution() } diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index 3e383e2c6fc..a7f9d819466 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -16,6 +16,8 @@ import { MothershipStreamV1ToolPhase, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { asRecord, markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' import type { @@ -149,17 +151,37 @@ export function abortPendingToolIfStreamDead( if (!options.abortSignal?.aborted && !context.wasAborted) { return false } - toolCall.status = MothershipStreamV1ToolOutcome.cancelled - toolCall.endTime = Date.now() + const abortReason = options.abortSignal?.aborted + ? String(options.abortSignal.reason ?? 'unknown') + : undefined + // Go through the canonical terminal helper rather than stamping status by + // hand: it also writes `result`, and everything downstream that reads a + // finished tool call requires one. Leaving it unset made this call look + // terminal-but-incomplete, which the subagent join turned into a thrown + // "missing result" error that killed the whole turn. + setTerminalToolCallState(toolCall, { + status: MothershipStreamV1ToolOutcome.cancelled, + error: 'Tool was not dispatched because its stream had already been aborted', + }) markToolResultSeen(toolCallId) + // Sim's logs do not reach Loki and the trace span below is collected + // in-process but never exported, so the counter is the only signal that + // survives to somewhere queryable. + recordDegraded(CopilotDegradedReason.StreamDeadBeforeDispatch) + logger.warn('Cancelled tool call before dispatch: stream already aborted', { + toolCallId, + toolName: toolCall.name, + reason: 'stream_dead_before_dispatch', + abortSignalAborted: options.abortSignal?.aborted ?? false, + ...(abortReason ? { abortReason } : {}), + wasAborted: context.wasAborted ?? false, + }) const toolSpan = context.trace.startSpan(toolCall.name || 'unknown_tool', 'tool.execute', { toolCallId, toolName: toolCall.name, cancelReason: 'stream_dead_before_dispatch', abortSignalAborted: options.abortSignal?.aborted ?? false, - abortReason: options.abortSignal?.aborted - ? String(options.abortSignal.reason ?? 'unknown') - : undefined, + abortReason, wasAborted: context.wasAborted ?? false, }) context.trace.endSpan(toolSpan, 'cancelled') diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts index 68fb457f076..37fff6f90c7 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts @@ -9,6 +9,10 @@ import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/reque // all concurrent legs build one chat. This is the regression the inline comment // warns about — without per-leg isolation the orchestrator's pre-fanout content // gets multiplied by the leg count on merge. +// +// `wasAborted` is the one deliberate exception to "reset AND folded back": it is +// reset per leg but folded back only for a turn-level abort, because a fanout +// cancelling its own lanes must not mark the shared turn aborted. describe('resume leg context isolate/merge contract', () => { it('isolates the per-leg scalars while sharing the heavy accumulators by reference', () => { const base = createStreamingContext({ @@ -31,6 +35,9 @@ describe('resume leg context isolate/merge contract', () => { expect(leg.streamComplete).toBe(false) expect(leg.awaitingAsyncContinuation).toBeUndefined() expect(leg.completionStatus).toBeUndefined() + // A leg must never be born aborted — that is what let one cancelled lane + // cancel every tool dispatched on every lane created after it. + expect(leg.wasAborted).toBe(false) // A leg's own errors array is a fresh array (not the shared one) so a leg's // retry rollback can't truncate a sibling's errors. @@ -65,6 +72,34 @@ describe('resume leg context isolate/merge contract', () => { expect(base.completionStatus).toBe(MothershipStreamV1CompletionStatus.complete) }) + it('does not fold a fanout-induced abort onto the shared turn', () => { + // A lane that fails cancels its siblings by design, and each cancelled + // sibling returns normally with wasAborted set. Folding that marked the + // SHARED context aborted, so every leg created afterwards was born aborted + // and every tool it dispatched was cancelled before dispatch — which the + // subagent join then reported as a fatal "missing result". + const base = createStreamingContext({}) + const leg = makeResumeLegContext(base) + leg.wasAborted = true + + mergeResumeLegOutputs(base, leg, false) + + expect(base.wasAborted).toBe(false) + }) + + it('folds a turn-level abort onto the shared turn', () => { + // The other half: a real Stop (or an observed abort marker) must reach the + // shared context, because that is what classifies the request as cancelled + // rather than successful. + const base = createStreamingContext({}) + const leg = makeResumeLegContext(base) + leg.wasAborted = true + + mergeResumeLegOutputs(base, leg, true) + + expect(base.wasAborted).toBe(true) + }) + it('leaves the turn unfinished when only child legs fold back', () => { const base = createStreamingContext() diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 5f535532f55..4b86ccb104f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -1759,6 +1759,61 @@ describe('runCopilotLifecycle', () => { } }) + it('retries an initial stream that ended before its checkpoint pause with one request identity', async () => { + const headers: Array> = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + headers.push(fetchOptions.headers as Record) + context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) + throw new StreamEndedWithoutTerminalError('/api/mothership') + } + ) + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + headers.push(fetchOptions.headers as Record) + context.streamComplete = true + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-initial-retry' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + simRequestId: 'request-initial-retry', + executionContext, + } + ) + + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(headers.map((value) => value['X-Sim-Request-ID'])).toEqual([ + 'request-initial-retry', + 'request-initial-retry', + ]) + expect(result).toEqual( + expect.objectContaining({ success: true, cancelled: false, errors: undefined }) + ) + }) + it('does not retry a resume leg the backend already claimed and ended early', async () => { const executionContext: ExecutionContext = { userId: 'user-1', @@ -2121,4 +2176,103 @@ describe('runCopilotLifecycle', () => { vi.useRealTimers() } }) + it('completes the turn when a pending subagent tool has no result', async () => { + // A tool that never reached a terminal state used to throw + // "Cannot resume subagent chain ...: missing result for tool call ...", + // which inside a fanout cancelled every sibling lane and reported the whole + // request as an error blaming an unrelated tool. Synthesize the failure Go + // already writes for itself instead, and let the turn finish. + const bodies: Array> = [] + mockRunStreamLoop.mockImplementation( + async ( + fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + if (!fetchUrl.includes('/api/tools/resume')) { + context.awaitingAsyncContinuation = { + checkpointId: 'cp-root', + pendingToolCallIds: [], + frames: [ + { + parentToolCallId: 'subagent-file', + parentToolName: 'file', + pendingToolIds: ['tool-never-dispatched'], + checkpointId: 'cp-file', + }, + ], + } + return + } + bodies.push(JSON.parse(String(fetchOptions.body))) + context.streamComplete = true + context.completionStatus = MothershipStreamV1CompletionStatus.complete + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-missing-subagent-result' }, + { userId: 'user-1', workspaceId: 'ws-1' } + ) + + expect(bodies).toHaveLength(1) + expect(bodies[0].checkpointId).toBe('cp-file') + expect(bodies[0].results).toEqual([ + expect.objectContaining({ + callId: 'tool-never-dispatched', + success: false, + data: { error: expect.stringContaining('no result was returned') }, + }), + ]) + expect(result.success).toBe(true) + }) + + it('classifies a Stop landing during a subagent fanout as cancelled', async () => { + // Guards the trap in the fanout fix: `wasAborted` is now isolated per leg, so + // a user Stop must still reach the turn — via the abort signal or the folded + // turn-level abort — or a cancelled turn would be reported as a success. + const controller = new AbortController() + mockRunStreamLoop.mockImplementation( + async ( + fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + if (!fetchUrl.includes('/api/tools/resume')) { + context.toolCalls.set('tool-done', { + id: 'tool-done', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true }, + endTime: Date.now(), + }) + context.awaitingAsyncContinuation = { + checkpointId: 'cp-root', + pendingToolCallIds: [], + frames: [ + { + parentToolCallId: 'subagent-file', + parentToolName: 'file', + pendingToolIds: ['tool-done'], + checkpointId: 'cp-file', + }, + ], + } + return + } + // The user hits Stop mid-fanout. `wasAborted` is isolated per leg now, so + // the turn's own signal is what has to carry the cancellation into the + // classification — reading only `context.wasAborted` reports success. + controller.abort('user_stop') + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-stop-during-fanout' }, + { userId: 'user-1', workspaceId: 'ws-1', abortSignal: controller.signal } + ) + + expect(result.cancelled).toBe(true) + expect(result.success).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 389f31564a2..f8288661998 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -28,6 +28,7 @@ import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/copilot/request/context/request-context' import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' @@ -37,6 +38,8 @@ import { runStreamLoop, StreamEndedWithoutTerminalError, } from '@/lib/copilot/request/go/stream' +import { recordDegraded } from '@/lib/copilot/request/metrics' +import { AbortReason } from '@/lib/copilot/request/session/abort-reason' import { getToolCallTerminalData, requireToolCallStateResult, @@ -346,7 +349,13 @@ export async function runCopilotLifecycle( // the work the user watched succeed. const backendFinishedTurn = context.completionStatus === MothershipStreamV1CompletionStatus.complete - const succeeded = !context.wasAborted && (backendFinishedTurn || context.errors.length === 0) + // Consult the lifecycle signal as well as the flag. `context.wasAborted` is + // only reached from a fanout leg through the (deliberately asymmetric) merge + // in `mergeResumeLegOutputs`, so a Stop landing mid-fanout could otherwise + // classify the turn as a success. Mirrors the check already used below on + // the throw path. + const turnWasAborted = context.wasAborted || (lifecycleOptions.abortSignal?.aborted ?? false) + const succeeded = !turnWasAborted && (backendFinishedTurn || context.errors.length === 0) const result: OrchestratorResult = { success: succeeded, @@ -359,7 +368,7 @@ export async function runCopilotLifecycle( // path, but practically that doesn't happen in the success // branch here — if there are errors we never reach a // wasAborted-without-errors state. - cancelled: context.wasAborted && context.errors.length === 0, + cancelled: turnWasAborted && context.errors.length === 0, content: resultContent(context, lifecycleOptions), contentBlocks: context.contentBlocks, toolCalls: buildToolCallSummaries(context), @@ -463,10 +472,12 @@ function isPerSubagentContinuation(c: AsyncContinuation): boolean { // every resume leg), so the auth/source/version headers can't drift between the // sequential path and the concurrent per-subagent resume legs. function mothershipRequestHeaders( - hostedBillingRequest?: AttributedBillingRequestEnvelope + hostedBillingRequest?: AttributedBillingRequestEnvelope, + simRequestId?: string ): Record { return { 'Content-Type': 'application/json', + ...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}), ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), ...getMothershipSourceEnvHeaders(), 'X-Client-Version': SIM_AGENT_VERSION, @@ -498,6 +509,12 @@ function mothershipRequestHeaders( // - completionStatus: the backend's terminal verdict, set only on the leg that // carries the turn to its end; a stale one from a sibling would speak for a // turn that leg never finished. +// - wasAborted: the ONE field with an asymmetric fold. Cancelling a fanout +// cancels its siblings by design, and each cancelled sibling returns +// normally with wasAborted set — folding that unconditionally marked the +// SHARED context aborted, so every later leg was born aborted and every tool +// it dispatched was cancelled before dispatch. Reset per leg, and fold back +// only for a turn-level abort (see mergeResumeLegOutputs). // When adding a per-leg field, update BOTH functions (and the contract test in // resume-leg-context.test.ts). Exported only for that test. export function makeResumeLegContext(base: StreamingContext): StreamingContext { @@ -511,19 +528,30 @@ export function makeResumeLegContext(base: StreamingContext): StreamingContext { cost: undefined, errors: [], completionStatus: undefined, + wasAborted: false, } } // mergeResumeLegOutputs folds a finished leg's isolated scalars back into the // shared context. Child (subagent-lane) legs leave the join scalars empty; only // the join-carrying leg (which streams the orchestrator continuation) sets them. -export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void { +// +// `turnWasAborted` is the caller's answer to "was this abort the turn's, or just +// this fanout cancelling its own lanes?". Only a turn-level abort belongs on the +// shared context: it is what `runCopilotLifecycle` reads to classify the request +// as cancelled, and on the headless path (which never wires `onAbortObserved`) +// it is the only record that the abort marker was ever observed. +export function mergeResumeLegOutputs( + context: StreamingContext, + leg: StreamingContext, + turnWasAborted = true +): void { if (leg.accumulatedContent) context.accumulatedContent += leg.accumulatedContent if (leg.finalAssistantContent) context.finalAssistantContent += leg.finalAssistantContent if (leg.usage) context.usage = leg.usage if (leg.cost) context.cost = leg.cost if (leg.sawMainToolCall) context.sawMainToolCall = true - if (leg.wasAborted) context.wasAborted = true + if (leg.wasAborted && turnWasAborted) context.wasAborted = true if (leg.errors.length > 0) context.errors.push(...leg.errors) if (leg.completionStatus) context.completionStatus = leg.completionStatus } @@ -537,26 +565,61 @@ async function waitForToolIds(context: StreamingContext, toolIds: string[]): Pro if (promises.length > 0) await Promise.allSettled(promises) } -function collectResultsForToolIds( +interface ResumeToolResult { + callId: string + name: string + data: unknown + success: boolean +} + +/** + * Build the resume payload entry for one pending tool call. + * + * A tool that never reached a terminal state has no result to send. That used to + * throw — which turned one incomplete tool into a dead turn, and inside a + * subagent fanout cancelled every sibling lane and reported the whole request as + * an error blaming an unrelated tool. Synthesize the same failure Go already + * writes for itself when Sim posts nothing for a pending call, so the model sees + * one failed tool and the turn carries on. Still logged at error level: getting + * here is a bug, it just must not be fatal. + */ +function buildResumeToolResult( context: StreamingContext, - toolIds: string[], - checkpointId: string -): Array<{ callId: string; name: string; data: unknown; success: boolean }> { - return toolIds.map((toolCallId) => { - const tool = context.toolCalls.get(toolCallId) - if (!tool || !tool.result) { - throw new Error( - `Cannot resume subagent chain ${checkpointId}: missing result for tool call ${toolCallId}` - ) - } - const name = tool.name || '' + toolCallId: string, + checkpointId: string | undefined +): ResumeToolResult { + const tool = context.toolCalls.get(toolCallId) + if (!tool || !tool.result) { + recordDegraded(CopilotDegradedReason.MissingToolResult) + logger.error('Missing tool result for pending tool call; synthesizing a failure', { + toolCallId, + checkpointId, + hasToolEntry: !!tool, + toolName: tool?.name, + toolStatus: tool?.status, + hasPendingPromise: context.pendingToolPromises.has(toolCallId), + }) return { callId: toolCallId, - name, - data: getToolCallTerminalData(tool), - success: requireToolCallStateResult(tool).success, + name: tool?.name || '', + data: { error: `no result was returned for tool call ${toolCallId}` }, + success: false, } - }) + } + return { + callId: toolCallId, + name: tool.name || '', + data: getToolCallTerminalData(tool), + success: requireToolCallStateResult(tool).success, + } +} + +function collectResultsForToolIds( + context: StreamingContext, + toolIds: string[], + checkpointId: string +): ResumeToolResult[] { + return toolIds.map((toolCallId) => buildResumeToolResult(context, toolCallId, checkpointId)) } // runResumeLegWithRetry runs ONE resume POST with the same retryable-error + @@ -583,7 +646,7 @@ async function runResumeLegWithRetry( url, { method: 'POST', - headers: mothershipRequestHeaders(hostedBillingRequest), + headers: mothershipRequestHeaders(hostedBillingRequest, options.simRequestId), body: JSON.stringify(body), }, leg, @@ -622,6 +685,12 @@ async function driveOneChildChain( execContext: ExecutionContext, options: CopilotLifecycleOptions, baseURL: string, + /** + * The turn's own abort signal, NOT the fanout controller in `options`. Used to + * tell "the user stopped the turn" from "a lane failed and cancelled its + * siblings" when deciding whether a leg's abort belongs on the shared context. + */ + turnAbortSignal: AbortSignal | undefined, workspaceId?: string, hostedBillingRequest?: AttributedBillingRequestEnvelope ): Promise { @@ -642,6 +711,18 @@ async function driveOneChildChain( const results = collectResultsForToolIds(context, toolIds, checkpointId) const leg = makeResumeLegContext(context) + // The abort marker is turn-scoped (keyed on the shared messageId), so a leg + // that observes it at body close IS a turn-level abort — and on the headless + // path, where `onAbortObserved` is never wired to the turn controller, this + // is the only record of it. + let markerObserved = false + const legOptions: CopilotLifecycleOptions = { + ...options, + onAbortObserved: (reason) => { + if (reason === AbortReason.MarkerObservedAtBodyClose) markerObserved = true + options.onAbortObserved?.(reason) + }, + } await runResumeLegWithRetry( `${baseURL}/api/tools/resume`, { @@ -653,10 +734,10 @@ async function driveOneChildChain( }, leg, execContext, - options, + legOptions, hostedBillingRequest ) - mergeResumeLegOutputs(context, leg) + mergeResumeLegOutputs(context, leg, markerObserved || (turnAbortSignal?.aborted ?? false)) const cont = leg.awaitingAsyncContinuation if (!cont) { @@ -735,6 +816,7 @@ async function driveSubagentChains( execContext, legOptions, baseURL, + parentSignal, workspaceId, hostedBillingRequest ).catch((error) => { @@ -769,9 +851,14 @@ async function runCheckpointLoop( let route = initialRoute let payload: Record = initialPayload let resumeAttempt = 0 + let initialAttempt = 0 const callerOnEvent = options.onEvent const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId }) const lifecycleWorkspaceId = nonBlankString(options.workspaceId) + const mothershipRequestId = nonBlankString(options.simRequestId) ?? generateId() + if (!options.simRequestId) { + options = { ...options, simRequestId: mothershipRequestId } + } const systemPromptOverride = env.MSHIP_SYSPROMPT_OVERRIDE if (typeof systemPromptOverride === 'string' && systemPromptOverride.trim() !== '') { @@ -855,7 +942,7 @@ async function runCheckpointLoop( `${mothershipBaseURL}${route}`, { method: 'POST', - headers: mothershipRequestHeaders(hostedBillingRequest), + headers: mothershipRequestHeaders(hostedBillingRequest, mothershipRequestId), body: JSON.stringify(payload), }, context, @@ -870,6 +957,7 @@ async function runCheckpointLoop( context.trace.endSpan(streamSpan, streamStatus) context.trace.setActiveSpan(undefined) resumeAttempt = 0 + initialAttempt = 0 } catch (streamError) { context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error) context.trace.setActiveSpan(undefined) @@ -877,22 +965,27 @@ async function runCheckpointLoop( await handleBillingLimitResponse(streamError.userId, context, execContext, options) break } - if ( - isResume && - isRetryableStreamError(streamError) && - resumeAttempt < MAX_RESUME_ATTEMPTS - 1 - ) { + const attempt = isResume ? resumeAttempt : initialAttempt + const retryable = isResume + ? isRetryableStreamError(streamError) + : isRetryableInitialStreamError(streamError) + if (retryable && attempt < MAX_RESUME_ATTEMPTS - 1) { // Discard errors recorded during this failed attempt; we're about to // redo this leg and a clean retry must not finalize as `error`. context.errors.length = errorsBeforeAttempt - resumeAttempt++ - const backoff = RESUME_BACKOFF_MS[resumeAttempt - 1] ?? 1000 - logger.warn('Resume stream failed, retrying', { - attempt: resumeAttempt + 1, - maxAttempts: MAX_RESUME_ATTEMPTS, - backoffMs: backoff, - error: toError(streamError).message, - }) + if (isResume) resumeAttempt++ + else initialAttempt++ + const nextAttempt = isResume ? resumeAttempt : initialAttempt + const backoff = RESUME_BACKOFF_MS[nextAttempt - 1] ?? 1000 + logger.warn( + isResume ? 'Resume stream failed, retrying' : 'Initial stream failed, retrying', + { + attempt: nextAttempt + 1, + maxAttempts: MAX_RESUME_ATTEMPTS, + backoffMs: backoff, + error: toError(streamError).message, + } + ) await sleepWithAbort(backoff, options.abortSignal) continue } @@ -1047,37 +1140,14 @@ async function runCheckpointLoop( break } - const results: Array<{ - callId: string - name: string - data: unknown - success: boolean - }> = [] + const results: ResumeToolResult[] = [] for (const toolCallId of continuation.pendingToolCallIds) { if (isAborted(options, context)) { cancelPendingTools(context) context.awaitingAsyncContinuation = undefined break } - const tool = context.toolCalls.get(toolCallId) - if (!tool || !tool.result) { - logger.error('Missing tool result for pending tool call', { - toolCallId, - checkpointId: continuation.checkpointId, - hasToolEntry: !!tool, - toolName: tool?.name, - toolStatus: tool?.status, - hasPendingPromise: context.pendingToolPromises.has(toolCallId), - }) - throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) - } - const name = tool.name || '' - results.push({ - callId: toolCallId, - name, - data: getToolCallTerminalData(tool), - success: requireToolCallStateResult(tool).success, - }) + results.push(buildResumeToolResult(context, toolCallId, continuation.checkpointId)) } if (isAborted(options, context)) { @@ -1325,6 +1395,25 @@ function isRetryableStreamError(error: unknown): boolean { return false } +/** + * Initial requests use a durable request identity and the backend's checkpoint + * delivery reservation. Reposting a transport-ambiguous initial leg is safe: + * Go redelivers an untouched committed pause, starts a request that never + * anchored, or fails closed when the accepted leg has no recoverable pause. + */ +function isRetryableInitialStreamError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return false + } + if (error instanceof StreamEndedWithoutTerminalError) { + return true + } + if (error instanceof CopilotBackendError) { + return error.status !== undefined && error.status >= 500 + } + return error instanceof TypeError +} + function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { if (!abortSignal) { return sleep(ms) diff --git a/apps/sim/lib/copilot/request/metrics.ts b/apps/sim/lib/copilot/request/metrics.ts index d3bfb804382..7ad1ed582ae 100644 --- a/apps/sim/lib/copilot/request/metrics.ts +++ b/apps/sim/lib/copilot/request/metrics.ts @@ -11,6 +11,7 @@ import { type Counter, type Histogram, metrics } from '@opentelemetry/api' import { Metric } from '@/lib/copilot/generated/metrics-v1' import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import type { CopilotDegradedReasonValue } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' // MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs @@ -26,6 +27,7 @@ const BYTE_BUCKETS = [1024, 8192, 65536, 262144, 1048576, 4194304, 16777216, 671 interface CopilotMeterInstruments { toolDuration: Histogram toolCalls: Counter + degradedCount: Counter vfsMaterializeDuration: Histogram fileReadDuration: Histogram fileReadBytes: Histogram @@ -45,6 +47,7 @@ function instruments(): CopilotMeterInstruments { advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, }), toolCalls: meter.createCounter(Metric.CopilotToolCalls), + degradedCount: meter.createCounter(Metric.CopilotDegradedCount), vfsMaterializeDuration: meter.createHistogram(Metric.CopilotVfsMaterializeDuration, { unit: 'ms', advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, @@ -97,6 +100,18 @@ export function recordSimToolMetric( if (durationMs >= 0) toolDuration.record(durationMs, attrs) } +// recordDegraded counts one non-fatal fallback, labelled by the bounded reason +// it took. Every degradation path reports here so "are we degrading, and why" is +// one query instead of a per-incident investigation — and so a path that should +// be impossible can be alerted on at > 0. Sim's own logs do not reach Loki and +// the in-process TraceCollector is not exported, so without this a fallback is +// invisible. +export function recordDegraded(reason: CopilotDegradedReasonValue): void { + instruments().degradedCount.add(1, { + [TraceAttr.CopilotDegradedReason]: reason, + }) +} + // recordVfsMaterialize records VFS materialization time. Call once per phase // with that phase's duration and once with phase="total" for the whole op, so // the dashboard can show total + per-phase. phase must be a bounded value. diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts index 328d62c6d39..d786190a33a 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts @@ -4,10 +4,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { waitForWorkflowToolCompletion, claimWorkflowToolExecution } = vi.hoisted(() => ({ - waitForWorkflowToolCompletion: vi.fn(), - claimWorkflowToolExecution: vi.fn(), -})) +const { waitForWorkflowToolCompletion, claimWorkflowToolExecution, recordDegraded } = vi.hoisted( + () => ({ + waitForWorkflowToolCompletion: vi.fn(), + claimWorkflowToolExecution: vi.fn(), + recordDegraded: vi.fn(), + }) +) + +vi.mock('@/lib/copilot/request/metrics', () => ({ recordDegraded })) vi.mock('@/lib/copilot/request/tools/client', () => ({ waitForWorkflowToolCompletion, @@ -84,6 +89,9 @@ describe('raceWorkflowToolClientPickup', () => { expect(outcome.winner).toBe('sim') expect(outcome.signal).toEqual({ status: 'success' }) + // Falling back is non-fatal, so it has to be countable — Sim logs do not + // reach Loki and this path emits no exported span. + expect(recordDegraded).toHaveBeenCalledWith('client_pickup_timeout') expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) expect(params.runOnServer).toHaveBeenCalledTimes(1) // The claimed id is what the server run must bind to. diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts index 5e63209ef6d..6a5a2df4c0b 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts +++ b/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts @@ -7,6 +7,8 @@ import type { AsyncTerminalCompletionSnapshot, } from '@/lib/copilot/async-runs/lifecycle' import { claimWorkflowToolExecution } from '@/lib/copilot/async-runs/repository' +import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/copilot/request/metrics' import { waitForWorkflowToolCompletion } from '@/lib/copilot/request/tools/client' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -121,6 +123,7 @@ export async function raceWorkflowToolClientPickup( return { winner: 'client', completion: await clientWait } } + recordDegraded(CopilotDegradedReason.ClientPickupTimeout) logger.info('No client picked up workflow tool within grace; running it server-side', { toolCallId, workflowId, diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index 7b4817d0b89..ccf8c55de2d 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -451,6 +451,38 @@ describe('run tool execution cancellation', () => { ) }) + it('reports the real failure reason so the agent can correct its arguments', async () => { + // A generic "Workflow execution failed." told the model nothing, so it could + // not fix a rejected binding or an undeployed workflow on retry. + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + executeWorkflowWithFullLogging.mockRejectedValueOnce( + new MockExecutionStreamHttpError( + 'This Copilot workflow tool call is bound to a different workflow', + 403, + 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH' + ) + ) + + executeRunToolOnClient('tool-binding-rejected', 'run_workflow', { workflowId: 'wf-1' }) + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/confirm', + expect.objectContaining({ + body: expect.stringContaining('COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH'), + }) + ) + }) + const confirmBody = JSON.parse( + fetchMock.mock.calls.find(([url]) => url === '/api/copilot/confirm')?.[1]?.body as string + ) + expect(confirmBody.message).toBe( + 'This Copilot workflow tool call is bound to a different workflow' + ) + expect(confirmBody.status).toBe('error') + }) + it('drops a duplicate async launch without confirming or surfacing an error', async () => { // The server fallback (or another tab) already claimed this tool call. // Reporting an error here would overwrite a run that is in flight. diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts index 6ccb62f1f96..ac2ead76706 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -681,11 +681,20 @@ async function doExecuteRunTool( logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) const failedExecutionId = useExecutionStore.getState().getCurrentExecutionId(targetWorkflowId) ?? executionId + // Carry the real failure through instead of the generic "Workflow execution + // failed." — the agent can only correct a bad request (a rejected binding, + // an undeployed workflow) if it is told what was wrong. + const failureCode = isExecutionStreamHttpError(err) ? err.code : undefined await reportCompletion( toolCallId, MothershipStreamV1ToolOutcome.error, - getWorkflowToolCompletionMessage(MothershipStreamV1ToolOutcome.error), - undefined, + msg, + { + success: false, + workflowId: targetWorkflowId, + error: msg, + ...(failureCode ? { code: failureCode } : {}), + }, failedExecutionId ) } diff --git a/apps/sim/lib/copilot/tools/workflow-tools.test.ts b/apps/sim/lib/copilot/tools/workflow-tools.test.ts new file mode 100644 index 00000000000..b461634bad3 --- /dev/null +++ b/apps/sim/lib/copilot/tools/workflow-tools.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS, + classifyWorkflowToolBinding, + resolveWorkflowToolTargetId, +} from './workflow-tools' + +const runningToolCall = { + toolName: 'run_workflow', + status: 'running' as const, + permissionDecision: null, + args: { workflowId: 'workflow-1' }, +} + +const run = { userId: 'user-1', workflowId: 'workflow-1' } + +function classify(overrides: Partial[0]> = {}) { + return classifyWorkflowToolBinding({ + toolCall: runningToolCall, + run, + userId: 'user-1', + workflowId: 'workflow-1', + ...overrides, + }) +} + +describe('classifyWorkflowToolBinding', () => { + it('accepts a live call bound to the requested workflow', () => { + expect(classify()).toEqual({ ok: true }) + }) + + it.each([ + ['missing row', { toolCall: null }, COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.unknown], + [ + 'non-workflow tool', + { toolCall: { ...runningToolCall, toolName: 'read' } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.notWorkflowTool, + ], + [ + 'finished call', + { toolCall: { ...runningToolCall, status: 'completed' as const } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled, + ], + [ + 'unapproved call', + { toolCall: { ...runningToolCall, status: 'pending' as const } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.awaitingPermission, + ], + [ + 'another user', + { run: { ...run, userId: 'someone-else' } }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.foreignOwner, + ], + [ + 'another workflow', + { workflowId: 'workflow-2' }, + COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch, + ], + ])('rejects %s with its own reason', (_name, overrides, expected) => { + expect(classify(overrides)).toEqual({ ok: false, rejection: expected }) + }) + + it('reports a finished call as the same conflict the execution claim uses', () => { + // The client already treats this status/code pair as benign on both the sync + // and async paths, so a duplicate stops rendering as a workflow failure. + expect(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled).toMatchObject({ + statusCode: 409, + code: 'COPILOT_WORKFLOW_EXECUTION_CONFLICT', + }) + }) + + it('falls back to the run workflow only for rows persisted without a stamped target', () => { + // Kept for the deploy window: in-flight tool calls created before the target + // was stamped into args still resolve through the run. + expect(resolveWorkflowToolTargetId({}, 'workflow-1')).toBe('workflow-1') + expect(classify({ toolCall: { ...runningToolCall, args: {} } })).toEqual({ ok: true }) + // A workspace chat has no run workflow, so nothing can rescue it. + expect( + classify({ toolCall: { ...runningToolCall, args: {} }, run: { ...run, workflowId: null } }) + ).toEqual({ ok: false, rejection: COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts index b493f55c193..a922924b7e0 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,8 +1,12 @@ +import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim/db/schema' import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncConfirmationStatus, + isTerminalAsyncStatus, + isWorkflowToolExecutionClaimable, } from '@/lib/copilot/async-runs/lifecycle' +import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' const WORKFLOW_TOOL_NAMES = [ 'run_workflow', @@ -31,6 +35,103 @@ const ASYNC_WORKFLOW_DEPLOYMENT_ERROR_BY_CODE = new Map [error.code, error]) ) +/** + * Why a workflow-tool execution request is not bound to the tool call it claims. + * + * These used to collapse into one opaque 403, which cost the caller any chance of + * telling "someone already ran this" (benign) from "this can never run" (a real + * defect), and told the model nothing it could act on. + * + * `alreadySettled` deliberately reuses `COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE`: + * it IS the same conflict as losing the execution claim, and both client paths + * already treat that status/code pair as benign and silent. + */ +export const COPILOT_WORKFLOW_TOOL_BINDING_ERRORS = { + unknown: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_UNKNOWN', + message: 'No Copilot workflow tool call matches this execution request', + statusCode: 404, + }, + notWorkflowTool: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_NOT_WORKFLOW_TOOL', + message: 'This Copilot tool call does not run a workflow', + statusCode: 403, + }, + alreadySettled: { + code: COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE, + message: 'This Copilot workflow tool call has already completed', + statusCode: 409, + }, + awaitingPermission: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_AWAITING_APPROVAL', + message: 'This Copilot workflow tool call has not been approved yet', + statusCode: 403, + }, + foreignOwner: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_FOREIGN_OWNER', + message: 'This Copilot workflow tool call belongs to a different user', + statusCode: 403, + }, + workflowMismatch: { + code: 'COPILOT_WORKFLOW_TOOL_BINDING_WORKFLOW_MISMATCH', + message: 'This Copilot workflow tool call is bound to a different workflow', + statusCode: 403, + }, +} as const + +export type CopilotWorkflowToolBindingError = + (typeof COPILOT_WORKFLOW_TOOL_BINDING_ERRORS)[keyof typeof COPILOT_WORKFLOW_TOOL_BINDING_ERRORS] + +export type CopilotWorkflowToolBindingResult = + | { ok: true } + | { ok: false; rejection: CopilotWorkflowToolBindingError } + +interface WorkflowToolBindingCandidate { + toolName: string + status: CopilotAsyncToolStatus + permissionDecision: CopilotToolPermissionDecision | null + args: unknown +} + +/** + * Decides whether an execution request may run under a Copilot workflow tool call. + * + * Non-authoritative on its own — the single-winner claim in + * `claimWorkflowToolExecution` is what actually prevents a double run. This exists + * so the request fails fast, and with a distinguishable reason, before spending + * admission and billing work on something that cannot legally run. + */ +export function classifyWorkflowToolBinding(params: { + toolCall: WorkflowToolBindingCandidate | null | undefined + run: { userId: string; workflowId: string | null } | null | undefined + userId: string + workflowId: string +}): CopilotWorkflowToolBindingResult { + const { toolCall, run, userId, workflowId } = params + const reject = (rejection: CopilotWorkflowToolBindingError) => ({ ok: false as const, rejection }) + + if (!toolCall) return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.unknown) + if (!isWorkflowToolName(toolCall.toolName)) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.notWorkflowTool) + } + if (!isWorkflowToolExecutionClaimable(toolCall.status, toolCall.permissionDecision)) { + // Split the one unclaimable bucket: a finished call is a benign duplicate, + // an unapproved one is a real refusal. + return reject( + isTerminalAsyncStatus(toolCall.status) + ? COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.alreadySettled + : COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.awaitingPermission + ) + } + if (!run || run.userId !== userId) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.foreignOwner) + } + if (resolveWorkflowToolTargetId(toolCall.args, run.workflowId) !== workflowId) { + return reject(COPILOT_WORKFLOW_TOOL_BINDING_ERRORS.workflowMismatch) + } + return { ok: true } +} + export function isWorkflowToolName(name: string): boolean { return WORKFLOW_TOOL_NAME_SET.has(name) } From eaee669c85e8c51d9e536632039229d3c270c662 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 19:21:42 -0700 Subject: [PATCH 005/135] Support split table tools and option recovery --- .../special-tags/special-tags.test.tsx | 30 + .../components/special-tags/special-tags.tsx | 43 ++ .../lib/copilot/generated/tool-catalog-v1.ts | 611 +++++++++++++++++- .../lib/copilot/generated/tool-schemas-v1.ts | 575 +++++++++++++++- apps/sim/lib/copilot/tools/server/router.ts | 10 + .../tools/server/table/table-automations.ts | 49 ++ .../tools/server/table/table-columns.ts | 42 ++ .../tools/server/table/table-enrichments.ts | 40 ++ .../tools/server/table/table-manage.ts | 37 ++ .../copilot/tools/server/table/table-rows.ts | 46 ++ .../tools/server/table/table-split.test.ts | 60 ++ apps/sim/lib/copilot/tools/tool-display.ts | 10 + 12 files changed, 1551 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/table/table-automations.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-columns.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-enrichments.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-manage.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-rows.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-split.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 80798ad2b09..6270761fc44 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1330,3 +1330,33 @@ describe('parseSpecialTags sim_key placeholder', () => { } }) }) + +describe('recoverTrailingBareOptions', () => { + const bareOptions = + '{"1": {"title": "Fix the tracker", "description": "debug"}, "2": {"title": "Inspect the miss", "description": "look"}}' + + it('renders a trailing bare-JSON options payload as an options card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, false) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(last.data['1']?.title).toBe('Fix the tracker') + } + expect(segments[0]).toEqual({ type: 'text', content: 'Here they are.' }) + }) + + it('never recovers mid-stream — a partial JSON tail must not flicker into a card', () => { + const { segments } = parseSpecialTags(`Here they are.\n${bareOptions}`, true) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('leaves ordinary JSON prose alone', () => { + const { segments } = parseSpecialTags('The config is {"retries": 3, "mode": "fast"}', false) + expect(segments.every((segment) => segment.type === 'text')).toBe(true) + }) + + it('does not double-render when a real options tag already parsed', () => { + const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) + expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0562ce7c616..21596a05ea1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1442,8 +1442,51 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS segments.push({ type: 'text', content }) } + if (!isStreaming) { + recoverTrailingBareOptions(segments) + } + return { segments, hasPendingTag } } +/** + * Recovers a trailing bare-JSON options payload the model emitted WITHOUT the + * `` wrapper (observed when an automation prompt asks the model to + * "(re)send suggested actions" and it answers with the JSON as content). The + * shape check is strict — a non-empty object whose every value is + * { title, description } with numeric-string keys — so ordinary JSON in prose + * cannot false-positive. Only a message's FINAL text segment is considered, + * mirroring the tag contract (options go last), and only when no options tag + * already parsed. Never applied mid-stream: a partial JSON tail must not + * flicker between prose and a card. + */ +function recoverTrailingBareOptions(segments: ContentSegment[]): void { + const last = segments[segments.length - 1] + if (!last || last.type !== 'text') return + if (segments.some((segment) => segment.type === 'options')) return + const text = last.content + if (!text.trimEnd().endsWith('}')) return + // The payload nests objects, so the START brace is the first one from which + // the remainder parses — probe brace positions left to right (bounded). + let start = -1 + let parsed: unknown + let probe = text.indexOf('{') + for (let attempts = 0; probe !== -1 && attempts < 20; attempts++) { + try { + parsed = JSON.parse(text.slice(probe).trim()) + start = probe + break + } catch { + probe = text.indexOf('{', probe + 1) + } + } + if (start === -1) return + if (!isOptionsTagData(parsed) || Object.keys(parsed as object).length === 0) return + if (!Object.keys(parsed as object).every((key) => /^\d+$/.test(key))) return + const prefix = text.slice(0, start).replace(/\s+$/, '') + segments.pop() + if (prefix) segments.push({ type: 'text', content: prefix }) + segments.push({ type: 'options', data: parsed }) +} interface SpecialTagsProps { segment: Exclude diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 91fe4c4c2e9..3d522d70351 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -113,6 +113,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -229,6 +234,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'table_automations' + | 'table_columns' + | 'table_enrichments' + | 'table_manage' + | 'table_rows' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' @@ -2067,7 +2077,7 @@ export const EnrichmentRun: ToolCatalogEntry = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4900,6 +4910,505 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const TableAutomations: ToolCatalogEntry = { + id: 'table_automations', + name: 'table_automations', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: 'Input column names that must be filled before the group runs a row.', + items: { type: 'string' }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { type: 'string' }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'New source block ID for this column.' }, + columnName: { + type: 'string', + description: 'The existing output column to remap (must be bound to this group).', + }, + path: { type: 'string', description: 'New dotted output path on the new block.' }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Source block ID inside the workflow.' }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { type: 'string', description: 'Dotted output path on the block.' }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { type: 'string', description: 'Row ID for cancel_table_runs with scope "row".' }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { type: 'string' }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableColumns: ToolCatalogEntry = { + id: 'table_columns', + name: 'table_columns', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { type: 'string', description: 'New column name (required for rename_column)' }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { type: 'string' }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableEnrichments: ToolCatalogEntry = { + id: 'table_enrichments', + name: 'table_enrichments', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { type: 'string' }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { type: 'string', description: 'Table ID (required for add_enrichment)' }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableManage: ToolCatalogEntry = { + id: 'table_manage', + name: 'table_manage', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { type: 'string', description: 'Table description (optional for create)' }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { type: 'string', description: 'Table name (required for create)' }, + newName: { type: 'string', description: 'New table name (required for rename)' }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const TableRows: ToolCatalogEntry = { + id: 'table_rows', + name: 'table_rows', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { type: 'string', description: 'Row ID (required for update_row, delete_row)' }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { type: 'string' }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -5788,6 +6297,101 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TableAutomationsOperation = { + listWorkflowOutputs: 'list_workflow_outputs', + addWorkflowGroup: 'add_workflow_group', + updateWorkflowGroup: 'update_workflow_group', + deleteWorkflowGroup: 'delete_workflow_group', + addWorkflowGroupOutput: 'add_workflow_group_output', + deleteWorkflowGroupOutput: 'delete_workflow_group_output', + runColumn: 'run_column', + cancelTableRuns: 'cancel_table_runs', +} as const + +export type TableAutomationsOperation = + (typeof TableAutomationsOperation)[keyof typeof TableAutomationsOperation] + +export const TableAutomationsOperationValues = [ + TableAutomationsOperation.listWorkflowOutputs, + TableAutomationsOperation.addWorkflowGroup, + TableAutomationsOperation.updateWorkflowGroup, + TableAutomationsOperation.deleteWorkflowGroup, + TableAutomationsOperation.addWorkflowGroupOutput, + TableAutomationsOperation.deleteWorkflowGroupOutput, + TableAutomationsOperation.runColumn, + TableAutomationsOperation.cancelTableRuns, +] as const + +export const TableColumnsOperation = { + addColumn: 'add_column', + renameColumn: 'rename_column', + deleteColumn: 'delete_column', + updateColumn: 'update_column', +} as const + +export type TableColumnsOperation = + (typeof TableColumnsOperation)[keyof typeof TableColumnsOperation] + +export const TableColumnsOperationValues = [ + TableColumnsOperation.addColumn, + TableColumnsOperation.renameColumn, + TableColumnsOperation.deleteColumn, + TableColumnsOperation.updateColumn, +] as const + +export const TableEnrichmentsOperation = { + listEnrichments: 'list_enrichments', + addEnrichment: 'add_enrichment', +} as const + +export type TableEnrichmentsOperation = + (typeof TableEnrichmentsOperation)[keyof typeof TableEnrichmentsOperation] + +export const TableEnrichmentsOperationValues = [ + TableEnrichmentsOperation.listEnrichments, + TableEnrichmentsOperation.addEnrichment, +] as const + +export const TableManageOperation = { + create: 'create', + createFromFile: 'create_from_file', + importFile: 'import_file', + rename: 'rename', +} as const + +export type TableManageOperation = (typeof TableManageOperation)[keyof typeof TableManageOperation] + +export const TableManageOperationValues = [ + TableManageOperation.create, + TableManageOperation.createFromFile, + TableManageOperation.importFile, + TableManageOperation.rename, +] as const + +export const TableRowsOperation = { + insertRow: 'insert_row', + batchInsertRows: 'batch_insert_rows', + updateRow: 'update_row', + batchUpdateRows: 'batch_update_rows', + deleteRow: 'delete_row', + batchDeleteRows: 'batch_delete_rows', + updateRowsByFilter: 'update_rows_by_filter', + deleteRowsByFilter: 'delete_rows_by_filter', +} as const + +export type TableRowsOperation = (typeof TableRowsOperation)[keyof typeof TableRowsOperation] + +export const TableRowsOperationValues = [ + TableRowsOperation.insertRow, + TableRowsOperation.batchInsertRows, + TableRowsOperation.updateRow, + TableRowsOperation.batchUpdateRows, + TableRowsOperation.deleteRow, + TableRowsOperation.batchDeleteRows, + TableRowsOperation.updateRowsByFilter, + TableRowsOperation.deleteRowsByFilter, +] as const + export const TerminalOperation = { run: 'run', read: 'read', @@ -6008,6 +6612,11 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [TableAutomations.id]: TableAutomations, + [TableColumns.id]: TableColumns, + [TableEnrichments.id]: TableEnrichments, + [TableManage.id]: TableManage, + [TableRows.id]: TableRows, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index ccc7a598c03..a3fd31e1c85 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1971,7 +1971,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enrichmentId: { type: 'string', description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via user_table.list_enrichments.", + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", enum: [ 'work-email', 'phone-number', @@ -4749,6 +4749,579 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + table_automations: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + "On add: true fires dep-satisfied rows immediately (only when the user explicitly asked); default false stages silently. On update: toggles the group's auto-fire on dep satisfaction.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow (add_workflow_group_output)', + }, + columnName: { + type: 'string', + description: + 'Target column name: required for delete_workflow_group_output (the bound column to drop); optional for add_workflow_group_output (auto-derived from path)', + }, + dependencies: { + type: 'object', + description: + 'Dependencies before a row runs: { columns?: string[] } of input column names that must be filled. Output columns of upstream groups are valid; a group cannot depend on its own outputs.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the group runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + deploymentMode: { + type: 'string', + description: + 'Which workflow version rows execute: "live" (default, editable draft — edits take effect immediately) or "deployed" (latest active deployment; fails if the workflow was never deployed).', + enum: ['live', 'deployed'], + }, + groupId: { + type: 'string', + description: + 'Workflow group ID (required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output)', + }, + groupIds: { + type: 'array', + description: 'Workflow group IDs to fire (required for run_column, non-empty)', + items: { + type: 'string', + }, + }, + mappingUpdates: { + type: 'array', + description: + 'Surgical per-output remap for update_workflow_group: each entry repoints ONE existing output column to a new (blockId, path) without touching the rest. Stale cells clear and backfill from saved execution logs where possible. Discover valid pairs via list_workflow_outputs first.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'New source block ID for this column.', + }, + columnName: { + type: 'string', + description: + 'The existing output column to remap (must be bound to this group).', + }, + path: { + type: 'string', + description: 'New dotted output path on the new block.', + }, + }, + required: ['columnName', 'blockId', 'path'], + }, + }, + name: { + type: 'string', + description: 'Display name for the group (optional on add/update)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + outputs: { + type: 'array', + description: + 'Outputs to surface as columns for add_workflow_group: each { blockId, path, columnName?, columnType? }; columnName auto-derives from path, columnType from the leaf type. Validated against list_workflow_outputs — invalid picks return the valid options. For update_workflow_group prefer add/delete_workflow_group_output and mappingUpdates; pass outputs only to restructure the whole set.', + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Source block ID inside the workflow.', + }, + columnName: { + type: 'string', + description: + 'Optional target column name; auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type; defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { + type: 'string', + description: 'Dotted output path on the block.', + }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block (add_workflow_group_output)', + }, + rowId: { + type: 'string', + description: 'Row ID for cancel_table_runs with scope "row".', + }, + rowIds: { + type: 'array', + description: + 'Optional row scope for run_column: only these rows are candidates (server eligibility still applies); omit for the whole table.', + items: { + type: 'string', + }, + }, + runMode: { + type: 'string', + description: + 'Run mode for run_column: "incomplete" (default) re-runs only rows with no output or a last failure; "all" re-runs every dep-satisfied row.', + enum: ['incomplete', 'all'], + }, + scope: { + type: 'string', + description: + 'Cancellation scope for cancel_table_runs: "all" (whole table) or "row" (requires rowId).', + enum: ['all', 'row'], + }, + tableId: { + type: 'string', + description: 'Table ID (required for everything except list_workflow_outputs)', + }, + workflowId: { + type: 'string', + description: + 'Workflow ID (required for add_workflow_group and list_workflow_outputs)', + }, + }, + }, + operation: { + type: 'string', + description: 'The automation operation to perform', + enum: [ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_columns: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + column: { + type: 'object', + description: + 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.', + }, + columnName: { + type: 'string', + description: + 'Column name (required for rename_column and update_column; single-column delete_column)', + }, + columnNames: { + type: 'array', + description: + 'Array of column names to delete at once (preferred for multi-column delete_column)', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + newName: { + type: 'string', + description: 'New column name (required for rename_column)', + }, + newType: { + type: 'string', + description: + 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { + type: 'string', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The column operation to perform', + enum: ['add_column', 'rename_column', 'delete_column', 'update_column'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_enrichments: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + autoRun: { + type: 'boolean', + description: + 'true fires dep-satisfied rows immediately on add (only when the user explicitly asked); default false stages silently — fire later via table_automations run_column.', + }, + dependencies: { + type: 'object', + description: + 'Optional dependency override: { columns?: string[] }; omit to default to the mapped input columns.', + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the enrichment runs a row.', + items: { + type: 'string', + }, + }, + }, + }, + enrichmentId: { + type: 'string', + description: + 'Enrichment registry ID for add_enrichment — discover via list_enrichments (e.g. work-email, phone-number, company-domain, company-info).', + }, + inputMappings: { + type: 'array', + description: + 'For add_enrichment: binds each enrichment input to an existing table column, as { inputName, columnName } where inputName is the enrichment input id from list_enrichments. Provide one for every required input.', + items: { + type: 'object', + properties: { + columnName: { + type: 'string', + description: 'Existing table column that supplies this input.', + }, + inputName: { + type: 'string', + description: 'Enrichment input id to bind (from list_enrichments).', + }, + }, + required: ['inputName', 'columnName'], + }, + }, + name: { + type: 'string', + description: + "Optional display name for the enrichment column group; defaults to the enrichment's registry name.", + }, + outputColumnNames: { + type: 'object', + description: + 'Optional output column name overrides, as { "": "" }; omit for defaults.', + additionalProperties: { + type: 'string', + description: 'Target column name for this enrichment output id.', + }, + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for add_enrichment)', + }, + }, + }, + operation: { + type: 'string', + description: 'The enrichment operation to perform', + enum: ['list_enrichments', 'add_enrichment'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_manage: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + description: { + type: 'string', + description: 'Table description (optional for create)', + }, + filePath: { + type: 'string', + description: + 'Canonical workspace file VFS path for create_from_file / import_file, e.g. files/{path}/{name}', + }, + mapping: { + type: 'object', + description: + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. null skips that header; omit a header to auto-map by sanitized name.', + additionalProperties: { + type: ['string', 'null'], + description: 'Target column name on the table; null skips that CSV header.', + }, + }, + mode: { + type: 'string', + description: + 'Import mode for import_file: append (default) adds rows; replace truncates existing rows in a transaction first.', + enum: ['append', 'replace'], + }, + name: { + type: 'string', + description: 'Table name (required for create)', + }, + newName: { + type: 'string', + description: 'New table name (required for rename)', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + schema: { + type: 'object', + description: + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.', + }, + tableId: { + type: 'string', + description: 'Table ID (required for import_file and rename)', + }, + }, + }, + operation: { + type: 'string', + description: 'The lifecycle operation to perform', + enum: ['create', 'create_from_file', 'import_file', 'rename'], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_rows: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + columnName: { + type: 'string', + description: 'Column to set when using the values map format of batch_update_rows', + }, + data: { + type: 'object', + description: + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', + }, + filter: { + type: 'object', + description: + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', + }, + limit: { + type: 'number', + description: + 'Optional cap on affected rows for the by-filter operations; omit to act on every match.', + }, + outputPath: { + type: 'string', + description: + 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', + }, + position: { + type: 'integer', + description: + 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below shift down; omit to append.', + }, + rowId: { + type: 'string', + description: 'Row ID (required for update_row, delete_row)', + }, + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { + type: 'string', + }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, terminal: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 2eb7b1d681a..7d87b432218 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -48,6 +48,11 @@ import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/genera import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +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 { 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' @@ -172,6 +177,11 @@ const baseServerToolRegistry: Record = { [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, [queryUserTableServerTool.name]: queryUserTableServerTool, + [tableManageServerTool.name]: tableManageServerTool, + [tableRowsServerTool.name]: tableRowsServerTool, + [tableColumnsServerTool.name]: tableColumnsServerTool, + [tableAutomationsServerTool.name]: tableAutomationsServerTool, + [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/table-automations.ts b/apps/sim/lib/copilot/tools/server/table/table-automations.ts new file mode 100644 index 00000000000..e2b94929404 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-automations.ts @@ -0,0 +1,49 @@ +import { TableAutomations } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableAutomationsArgs = { + operation: string + args?: Record +} + +type TableAutomationsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'list_workflow_outputs', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', +]) + +/** + * per-row workflow automations slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableAutomationsServerTool: BaseServerTool< + TableAutomationsArgs, + TableAutomationsResult +> = { + name: TableAutomations.id, + async execute(params: TableAutomationsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_automations does not support operation '${operation}' (allowed: list_workflow_outputs, add_workflow_group, update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output, run_column, cancel_table_runs); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-columns.ts b/apps/sim/lib/copilot/tools/server/table/table-columns.ts new file mode 100644 index 00000000000..9e4ebc2f499 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-columns.ts @@ -0,0 +1,42 @@ +import { TableColumns } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableColumnsArgs = { + operation: string + args?: Record +} + +type TableColumnsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'add_column', + 'rename_column', + 'delete_column', + 'update_column', +]) + +/** + * column DDL (add/rename/retype/delete) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableColumnsServerTool: BaseServerTool = { + name: TableColumns.id, + async execute(params: TableColumnsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_columns does not support operation '${operation}' (allowed: add_column, rename_column, delete_column, update_column); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts new file mode 100644 index 00000000000..caa317ebdfa --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts @@ -0,0 +1,40 @@ +import { TableEnrichments } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableEnrichmentsArgs = { + operation: string + args?: Record +} + +type TableEnrichmentsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['list_enrichments', 'add_enrichment']) + +/** + * prebuilt per-row enrichments slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableEnrichmentsServerTool: BaseServerTool< + TableEnrichmentsArgs, + TableEnrichmentsResult +> = { + name: TableEnrichments.id, + async execute(params: TableEnrichmentsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_enrichments does not support operation '${operation}' (allowed: list_enrichments, add_enrichment); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-manage.ts b/apps/sim/lib/copilot/tools/server/table/table-manage.ts new file mode 100644 index 00000000000..b8030fc5f43 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-manage.ts @@ -0,0 +1,37 @@ +import { TableManage } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableManageArgs = { + operation: string + args?: Record +} + +type TableManageResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set(['create', 'create_from_file', 'import_file', 'rename']) + +/** + * table lifecycle (create, create_from_file, import_file, rename) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableManageServerTool: BaseServerTool = { + name: TableManage.id, + async execute(params: TableManageArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_manage does not support operation '${operation}' (allowed: create, create_from_file, import_file, rename); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-rows.ts b/apps/sim/lib/copilot/tools/server/table/table-rows.ts new file mode 100644 index 00000000000..aec5ee08211 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-rows.ts @@ -0,0 +1,46 @@ +import { TableRows } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' + +type TableRowsArgs = { + operation: string + args?: Record +} + +type TableRowsResult = { + success: boolean + message: string + data?: any +} + +const ALLOWED_OPERATIONS = new Set([ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', +]) + +/** + * row data (insert/update/delete, batch and by-filter) slice of the split user_table surface. Copilot access control is a + * per-agent tool allowlist, so each slice gets its own tool name with its own + * operation contract — enforced here (where execution happens) on top of the + * schema enum in the Go catalog. Delegates to the shared user_table executor, + * so argument semantics stay identical by construction. + */ +export const tableRowsServerTool: BaseServerTool = { + name: TableRows.id, + async execute(params: TableRowsArgs, context?: ServerToolContext) { + const operation = params?.operation + if (!ALLOWED_OPERATIONS.has(operation)) { + return { + success: false, + message: `table_rows does not support operation '${operation}' (allowed: insert_row, batch_insert_rows, update_row, batch_update_rows, delete_row, batch_delete_rows, update_rows_by_filter, delete_rows_by_filter); other table operations live on their own table_* tools`, + } + } + return userTableServerTool.execute(params, context) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/table-split.test.ts b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts new file mode 100644 index 00000000000..00233836d39 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const executeUserTable = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ + userTableServerTool: { execute: executeUserTable }, +})) + +import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' +import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' +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' + +/** + * Every split tool delegates its own operations to the shared user_table + * executor untouched, and rejects operations that belong to a sibling slice + * without ever invoking it — the per-slice allowlist is the access contract. + */ +describe('split table tools', () => { + beforeEach(() => { + vi.clearAllMocks() + executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) + }) + + const cases = [ + { tool: tableManageServerTool, own: 'create', foreign: 'insert_row' }, + { tool: tableRowsServerTool, own: 'batch_update_rows', foreign: 'add_column' }, + { tool: tableColumnsServerTool, own: 'update_column', foreign: 'create' }, + { tool: tableAutomationsServerTool, own: 'run_column', foreign: 'add_enrichment' }, + { tool: tableEnrichmentsServerTool, own: 'add_enrichment', foreign: 'run_column' }, + ] as const + + it.each(cases)( + '$tool.name delegates $own and rejects $foreign', + async ({ tool, own, foreign }) => { + const context = { userId: 'user-1', workspaceId: 'workspace-1', copilotToolExecution: true } + const params = { operation: own, args: { tableId: 'table-1' } } + + await expect(tool.execute(params as never, context as never)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeUserTable).toHaveBeenCalledWith(params, context) + + executeUserTable.mockClear() + await expect( + tool.execute({ operation: foreign, args: { tableId: 'table-1' } } as never) + ).resolves.toMatchObject({ + success: false, + message: expect.stringContaining(foreign), + }) + expect(executeUserTable).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 9d3e30c37c6..f15c80c5224 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -444,6 +444,11 @@ const TOOL_TITLES: Record = { user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', + table_manage: 'Managing table', + table_rows: 'Editing table rows', + table_columns: 'Editing table columns', + table_automations: 'Managing table automations', + table_enrichments: 'Managing table enrichments', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', @@ -682,6 +687,11 @@ export function getToolDisplayTitle(name: string, args?: Record case 'knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': + case 'table_manage': + case 'table_rows': + case 'table_columns': + case 'table_automations': + case 'table_enrichments': return queryUserTableTitle(args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) From 3738347b05f5115c123ecd604ec2e07b4e5875d9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 12 Aug 2026 19:41:48 -0700 Subject: [PATCH 006/135] Harden VFS mutation handling --- .../copilot/tools/handlers/vfs-mutate.test.ts | 196 ++++++++++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 309 ++++++++++++------ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 40 ++- .../lib/folders/application/resource-vfs.ts | Bin 0 -> 14692 bytes .../knowledge/application/knowledge-vfs.ts | 154 ++++++++- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 12 + apps/sim/lib/table/application/operations.ts | 6 + apps/sim/lib/table/application/table-vfs.ts | 157 ++++++++- 9 files changed, 752 insertions(+), 124 deletions(-) create mode 100644 apps/sim/lib/folders/application/resource-vfs.ts diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index e19935bb9a4..3245a6b8977 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -42,8 +42,14 @@ const mocks = vi.hoisted(() => ({ deleteFileVfsItems: vi.fn(), renameTableVfs: vi.fn(), deleteTableVfs: vi.fn(), + transferTableVfs: vi.fn(), + createTableFolders: vi.fn(), + deleteTableFolders: vi.fn(), renameKnowledgeVfs: vi.fn(), deleteKnowledgeVfs: vi.fn(), + transferKnowledgeVfs: vi.fn(), + createKnowledgeFolders: vi.fn(), + deleteKnowledgeFolders: vi.fn(), })) vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) @@ -163,6 +169,18 @@ vi.mock('@/lib/table/application/table-vfs', () => ({ operation: tableOperations.deleteByVfsPath, execute: mocks.deleteTableVfs, }, + transferTableVfsItems: { + operation: tableOperations.moveByVfsPath, + execute: mocks.transferTableVfs, + }, + createTableVfsFolders: { + operation: tableOperations.createFolder, + execute: mocks.createTableFolders, + }, + deleteTableVfsFolders: { + operation: tableOperations.deleteFolder, + execute: mocks.deleteTableFolders, + }, })) vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ @@ -174,6 +192,18 @@ vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ operation: knowledgeOperations.deleteByVfsPath, execute: mocks.deleteKnowledgeVfs, }, + transferKnowledgeVfsItems: { + operation: knowledgeOperations.moveByVfsPath, + execute: mocks.transferKnowledgeVfs, + }, + createKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.createKnowledgeFolders, + }, + deleteKnowledgeVfsFolders: { + operation: knowledgeOperations.manageVfsFolders, + execute: mocks.deleteKnowledgeFolders, + }, })) vi.mock('@/lib/table/service', () => ({ @@ -776,15 +806,41 @@ describe('vfs mv/cp', () => { }) }) - it('rejects flat namespaces', async () => { + it('creates table folders through the table application operation', async () => { + mocks.createTableFolders.mockResolvedValue({ + outcomes: [ + { source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1', targetSegments: ['CRM'] }, + ], + }) + const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) - expect(result.success).toBe(false) + + expect(mocks.createTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + paths: [{ source: 'tables/CRM', segments: ['CRM'] }], + }, + }) + ) + expect(result.success).toBe(true) expect(result.output).toMatchObject({ - results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], + results: [{ from: 'tables/CRM', to: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], }) expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) + it('rejects the reserved knowledgebases/connectors folder path', async () => { + const result = await executeVfsMkdir({ paths: ['knowledgebases/connectors/sub'] }, context) + expect(result.success).toBe(false) + expect(result.output).toMatchObject({ + results: [ + { from: 'knowledgebases/connectors/sub', error: expect.stringContaining('reserved') }, + ], + }) + expect(mocks.createKnowledgeFolders).not.toHaveBeenCalled() + }) + it('rejects creation inside a locked workflow folder', async () => { mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [ @@ -804,30 +860,65 @@ describe('vfs mv/cp', () => { }) }) - describe('tables and knowledge bases (flat namespaces)', () => { - it('renames a table', async () => { + describe('tables and knowledge bases (foldered)', () => { + it('renames a table through the transfer application operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['Customers'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['tables/Leads'], destination: 'tables/Customers' }, context ) - expect(mocks.renameTableVfs).toHaveBeenCalledWith( + expect(mocks.transferTableVfs).toHaveBeenCalledWith( expect.objectContaining({ - input: { workspaceId: 'ws-1', sourceName: 'Leads', newName: 'Customers' }, + input: { + workspaceId: 'ws-1', + sources: [{ source: 'tables/Leads', segments: ['Leads'] }], + destination: { segments: ['Customers'], trailingSlash: false }, + }, }) ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) }) - it('rejects nested table destinations as flat-namespace violations', async () => { + it('moves a table into a folder, folders auto-created server-side', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/Leads', + kind: 'resource', + resourceId: 'tbl-1', + targetSegments: ['CRM', 'Leads'], + }, + ], + }) + const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, + { sources: ['tables/Leads'], destination: 'tables/CRM/' }, context ) - expect(result.success).toBe(false) - expect(result.error).toContain('flat namespace') - expect(mocks.renameTable).not.toHaveBeenCalled() + + expect(mocks.transferTableVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + destination: { segments: ['CRM'], trailingSlash: true }, + }), + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/CRM/Leads', kind: 'table' }], + }) }) it('rejects copying tables', async () => { @@ -840,12 +931,23 @@ describe('vfs mv/cp', () => { }) it('renames a knowledge base through trusted application operations', async () => { + mocks.transferKnowledgeVfs.mockResolvedValue({ + outcomes: [ + { + source: 'knowledgebases/Docs', + kind: 'resource', + resourceId: 'kb-1', + targetSegments: ['Product Docs'], + }, + ], + }) + const result = await executeVfsMv( { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, context ) - expect(mocks.renameKnowledgeVfs).toHaveBeenCalledWith( + expect(mocks.transferKnowledgeVfs).toHaveBeenCalledWith( expect.objectContaining({ principal: expect.objectContaining({ kind: 'delegated', @@ -855,8 +957,8 @@ describe('vfs mv/cp', () => { }), input: { workspaceId: 'ws-1', - sourceName: 'Docs', - newName: 'Product Docs', + sources: [{ source: 'knowledgebases/Docs', segments: ['Docs'] }], + destination: { segments: ['Product Docs'], trailingSlash: false }, }, }) ) @@ -864,7 +966,7 @@ describe('vfs mv/cp', () => { }) it('propagates knowledge application infrastructure failures', async () => { - mocks.renameKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) + mocks.transferKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) await expect( executeVfsMv( @@ -875,7 +977,7 @@ describe('vfs mv/cp', () => { }) it('preserves an actionable knowledge rename conflict', async () => { - mocks.renameKnowledgeVfs.mockRejectedValue( + mocks.transferKnowledgeVfs.mockRejectedValue( new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') ) @@ -912,11 +1014,71 @@ describe('vfs mv/cp', () => { input: { workspaceId: 'ws-1', sourceName: 'Docs', + sourceSegments: ['Docs'], }, }) ) }) + it('moves a whole table folder through the transfer operation', async () => { + mocks.transferTableVfs.mockResolvedValue({ + outcomes: [ + { + source: 'tables/CRM', + kind: 'folder', + resourceId: 'fld-1', + targetSegments: ['Archive', 'CRM'], + }, + ], + }) + + const result = await executeVfsMv( + { sources: ['tables/CRM'], destination: 'tables/Archive/' }, + context + ) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ to: 'tables/Archive/CRM', kind: 'table_folder' }], + }) + }) + + it('rm retargets to the folder cascade when the path is a folder', async () => { + mocks.deleteTableVfs.mockRejectedValue( + new OrchestrationError('invalid', 'tables/CRM is a folder; this operation takes a table.') + ) + mocks.deleteTableFolders.mockResolvedValue({ + outcomes: [{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1' }], + }) + + const result = await executeVfsRm({ paths: ['tables/CRM'] }, context) + + expect(mocks.deleteTableFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: 'ws-1', paths: [{ source: 'tables/CRM', segments: ['CRM'] }] }, + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + results: [{ from: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], + }) + }) + + it('deletes a nested knowledge base by its folder path', async () => { + const result = await executeVfsRm({ paths: ['knowledgebases/Legal/Contracts'] }, context) + + expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: 'ws-1', + sourceName: 'Contracts', + sourceSegments: ['Legal', 'Contracts'], + }, + }) + ) + expect(result.success).toBe(true) + }) + it('preserves an actionable knowledge delete failure', async () => { mocks.deleteKnowledgeVfs.mockRejectedValue( new OrchestrationError('not_found', 'Knowledge base no longer exists') diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 6516fe35f15..8fa50ce6d6f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -12,16 +12,23 @@ import { import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/files/file-folder-application' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import type { ResourceVfsOutcome } from '@/lib/folders/application/resource-vfs' import { + createKnowledgeVfsFolders, deleteKnowledgeBaseByVfsPath, - renameKnowledgeBaseByVfsPath, + deleteKnowledgeVfsFolders, + transferKnowledgeVfsItems, } from '@/lib/knowledge/application/knowledge-vfs' import { captureServerEvent } from '@/lib/posthog/server' -import { deleteTableByVfsPath, renameTableByVfsPath } from '@/lib/table/application/table-vfs' +import { + createTableVfsFolders, + deleteTableByVfsPath, + deleteTableVfsFolders, + transferTableVfsItems, +} from '@/lib/table/application/table-vfs' import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' import { copyWorkflowVfsItems, @@ -66,7 +73,15 @@ const RM_CATEGORY_REJECTIONS: Record = { interface VfsMutateOutcome { from: string to?: string - kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' + kind: + | 'file' + | 'file_folder' + | 'workflow' + | 'workflow_folder' + | 'table' + | 'table_folder' + | 'knowledge_base' + | 'knowledge_base_folder' id?: string error?: string } @@ -217,18 +232,72 @@ export async function executeVfsMkdir( } } + const folderedOutcomes = new Map() + for (const category of ['tables', 'knowledgebases'] as const) { + const categoryPaths = paths.filter((path) => topLevelSegment(path) === category) + if (categoryPaths.length === 0) continue + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + const reserved = categoryPaths.filter((path) => + isReservedKnowledgePath(category, decodeVfsPathSegments(path).slice(1)) + ) + for (const path of reserved) { + folderedOutcomes.set(path, { + from: path, + kind: folderKind, + error: '"knowledgebases/connectors" is a reserved path.', + }) + } + const eligible = categoryPaths.filter((path) => !folderedOutcomes.has(path)) + if (eligible.length === 0) continue + const input = { + workspaceId, + paths: eligible.map((path) => ({ + source: path, + segments: decodeVfsPathSegments(path).slice(1), + })), + } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, createTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, createKnowledgeVfsFolders, input) + for (const outcome of result.outcomes) { + folderedOutcomes.set(outcome.source, presentResourceVfsOutcome(category, outcome)) + } + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to create folders') + for (const path of eligible) { + folderedOutcomes.set(path, { from: path, kind: folderKind, error: message }) + } + } + } + const outcomes: VfsMutateOutcome[] = [] for (const path of paths) { const top = topLevelSegment(path) const segments = decodeVfsPathSegments(path).slice(1) - const kind = top === 'workflows' ? 'workflow_folder' : 'file_folder' - + const kind = + top === 'workflows' + ? 'workflow_folder' + : top === 'tables' + ? 'table_folder' + : top === 'knowledgebases' + ? 'knowledge_base_folder' + : 'file_folder' + + if (top === 'tables' || top === 'knowledgebases') { + outcomes.push( + folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' } + ) + continue + } if (top !== 'files' && top !== 'workflows') { const rejection = - top === 'tables' || top === 'knowledgebases' - ? `${top}/ is a flat namespace with no folders.` - : (CATEGORY_REJECTIONS[top] ?? - `"${path}" is not a folder target. mkdir supports files/ and workflows/ paths.`) + CATEGORY_REJECTIONS[top] ?? + `"${path}" is not a folder target. mkdir supports files/, workflows/, tables/, and knowledgebases/ paths.` outcomes.push({ from: path, kind, error: rejection }) continue } @@ -319,7 +388,14 @@ async function executeVfsMutate( case 'workflows': return await mutateWorkflows(verb, sources, destination, context, workspaceId) default: - return await renameFlatResource(verb, category, sources, destination, context, workspaceId) + return await transferFolderedResource( + verb, + category, + sources, + destination, + context, + workspaceId + ) } } catch (error) { if (error instanceof KnowledgeVfsInfrastructureError) { @@ -333,6 +409,28 @@ async function executeVfsMutate( } } +function presentResourceVfsOutcome( + category: 'tables' | 'knowledgebases', + outcome: ResourceVfsOutcome +): VfsMutateOutcome { + const resourceKind = category === 'tables' ? 'table' : 'knowledge_base' + const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' + return { + from: outcome.source, + ...(outcome.targetSegments + ? { to: `${category}/${encodeVfsPathSegments(outcome.targetSegments)}` } + : {}), + kind: outcome.kind === 'folder' ? folderKind : resourceKind, + id: outcome.resourceId, + error: outcome.error, + } +} + +/** knowledgebases/connectors is a virtual tree, not a knowledge base or folder. */ +function isReservedKnowledgePath(category: string, segments: readonly string[]): boolean { + return category === 'knowledgebases' && segments[0]?.toLowerCase() === 'connectors' +} + async function mutateWorkspaceFiles( verb: MutateVerb, sources: string[], @@ -419,7 +517,7 @@ async function mutateWorkflows( } } -async function renameFlatResource( +async function transferFolderedResource( verb: MutateVerb, category: 'tables' | 'knowledgebases', sources: string[], @@ -428,77 +526,49 @@ async function renameFlatResource( workspaceId: string ): Promise { const label = category === 'tables' ? 'Tables' : 'Knowledge bases' - const kind = category === 'tables' ? 'table' : 'knowledge_base' - if (verb === 'cp') { return { success: false, error: `${label} cannot be copied — duplication is not supported.` } } - if (sources.length > 1) { - return { success: false, error: `${label} are renamed one at a time.` } - } - const sourceSegments = decodeVfsPathSegments(sources[0]).slice(1) - const destSegments = decodeVfsPathSegments(destination).slice(1) - if (sourceSegments.length !== 1 || destSegments.length !== 1 || hasTrailingSlash(destination)) { - return { - success: false, - error: `${label} have a flat namespace with no folders — mv only renames them, e.g. mv({sources: ["${category}/Old Name"], destination: "${category}/New Name"}).`, + const sourceRefs = sources.map((source) => ({ + source, + segments: decodeVfsPathSegments(source).slice(1), + })) + const destinationSegments = decodeVfsPathSegments(destination).slice(1) + for (const ref of sourceRefs) { + if (isReservedKnowledgePath(category, ref.segments)) { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } } - - const sourceName = sourceSegments[0] - const newName = destSegments[0] - - if (category === 'tables') { - try { - const renamed = await executeCopilotTableUseCase( - context, - renameTableByVfsPath, - { workspaceId, sourceName, newName }, - {} - ) - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) - } catch (error) { - return { success: false, error: messageForExpectedTableVfsError(error) } - } + if (isReservedKnowledgePath(category, destinationSegments)) { + return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } } - if (newName.toLowerCase() === 'connectors') { - return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } + const input = { + workspaceId, + sources: sourceRefs, + destination: { + segments: destinationSegments, + trailingSlash: hasTrailingSlash(destination), + }, } + assertMutationNotAborted(context) try { - const renamed = await executeCopilotKnowledgeUseCase(context, renameKnowledgeBaseByVfsPath, { - workspaceId, - sourceName, - newName, - }) - logger.info('Renamed knowledge base via mv', { - knowledgeBaseId: renamed.id, - workspaceId, - }) - return buildResult(verb, [ - { - from: sources[0], - to: `knowledgebases/${normalizeVfsSegment(renamed.name)}`, - kind, - id: renamed.id, - }, - ]) + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, transferTableVfsItems, input, {}) + : await executeCopilotKnowledgeUseCase(context, transferKnowledgeVfsItems, input) + return buildResult( + verb, + result.outcomes.map((outcome) => presentResourceVfsOutcome(category, outcome)) + ) } catch (error) { - return { - success: false, - error: messageForKnowledgeVfsError( - error, - `Write access required to rename knowledge base "${sourceName}"` - ), - } + if (context.abortSignal?.aborted) throw error + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, `Write access required to move ${label.toLowerCase()}`) + return { success: false, error: message } } } @@ -641,31 +711,25 @@ function removeOne( } } -/** Resolves a flat tables/{name} or knowledgebases/{name} path to its single segment. */ -function flatResourceName(path: string): string | null { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length !== 1) return null - return segments[0] -} - async function removeTablePath( path: string, context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'table', - error: 'tables/ is a flat namespace — rm takes a single name, e.g. rm(["tables/Leads"]).', + error: 'rm takes a table or folder path, e.g. rm(["tables/Leads"]) or rm(["tables/CRM"]).', } } + const sourceName = segments[segments.length - 1] try { const deleted = await executeCopilotTableUseCase( context, deleteTableByVfsPath, - { workspaceId, sourceName }, + { workspaceId, sourceName, sourceSegments: segments }, {} ) captureServerEvent( @@ -677,7 +741,51 @@ async function removeTablePath( logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) return { from: path, kind: 'table', id: deleted.id } } catch (error) { - return { from: path, kind: 'table', error: messageForExpectedTableVfsError(error) } + const message = messageForExpectedTableVfsError(error) + const folderOutcome = await removeResourceFolderFallback( + 'tables', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'table', error: message } + } +} + +/** + * rm resolution is resource-first (matching mv); when the resource resolver + * reports the path IS a folder, the delete retargets to the folder cascade. + */ +async function removeResourceFolderFallback( + category: 'tables' | 'knowledgebases', + path: string, + segments: string[], + resourceError: string, + context: ExecutionContext, + workspaceId: string +): Promise { + if (!resourceError.includes('is a folder')) return null + const input = { workspaceId, paths: [{ source: path, segments }] } + try { + const result = + category === 'tables' + ? await executeCopilotTableUseCase(context, deleteTableVfsFolders, input, {}) + : await executeCopilotKnowledgeUseCase(context, deleteKnowledgeVfsFolders, input) + const outcome = result.outcomes[0] + return outcome ? presentResourceVfsOutcome(category, outcome) : null + } catch (error) { + const message = + category === 'tables' + ? messageForExpectedTableVfsError(error) + : messageForKnowledgeVfsError(error, 'Write access required to delete folders') + return { + from: path, + kind: category === 'tables' ? 'table_folder' : 'knowledge_base_folder', + error: message, + } } } @@ -686,16 +794,16 @@ async function removeKnowledgeBasePath( context: ExecutionContext, workspaceId: string ): Promise { - const sourceName = flatResourceName(path) - if (!sourceName) { + const segments = decodeVfsPathSegments(path).slice(1) + if (segments.length === 0) { return { from: path, kind: 'knowledge_base', - error: - 'knowledgebases/ is a flat namespace — rm takes a single name, e.g. rm(["knowledgebases/support-docs"]).', + error: 'rm takes a knowledge base or folder path, e.g. rm(["knowledgebases/support-docs"]).', } } - if (sourceName.toLowerCase() === 'connectors') { + const sourceName = segments[segments.length - 1] + if (isReservedKnowledgePath('knowledgebases', segments)) { return { from: path, kind: 'knowledge_base', @@ -706,6 +814,7 @@ async function removeKnowledgeBasePath( const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { workspaceId, sourceName, + sourceSegments: segments, }) PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) logger.info('Deleted knowledge base via rm', { @@ -714,13 +823,19 @@ async function removeKnowledgeBasePath( }) return { from: path, kind: 'knowledge_base', id: deleted.id } } catch (error) { - return { - from: path, - kind: 'knowledge_base', - error: messageForKnowledgeVfsError( - error, - `Write access required to delete knowledge base "${sourceName}"` - ), - } + const message = messageForKnowledgeVfsError( + error, + `Write access required to delete knowledge base "${sourceName}"` + ) + const folderOutcome = await removeResourceFolderFallback( + 'knowledgebases', + path, + segments, + message, + context, + workspaceId + ) + if (folderOutcome) return folderOutcome + return { from: path, kind: 'knowledge_base', error: message } } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 1cdb3dd34b2..96ca9331725 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -109,6 +109,7 @@ import { listWorkspaceSandboxes, } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { isIntegrationDeploymentAvailableForVisibility, isOAuthServiceDeploymentAvailable, @@ -1612,6 +1613,26 @@ export class WorkspaceVFS { return buildVfsFolderPathMap(folders) } + /** + * Folder paths for a non-workflow resource tree (tables, knowledge bases), + * plus `.folder` markers so empty folders are discoverable via glob — the + * same contract workflows/ has. Returns folderId → encoded folder path. + */ + private async registerResourceFolders( + workspaceId: string, + resourceType: 'table' | 'knowledge_base', + rootSegment: 'tables' | 'knowledgebases' + ): Promise> { + const folders = await listFoldersForWorkspace(workspaceId, 'active', resourceType) + const paths = buildVfsFolderPathMap( + folders.map((f) => ({ folderId: f.id, folderName: f.name, parentId: f.parentId })) + ) + for (const folderPath of paths.values()) { + this.files.set(`${rootSegment}/${folderPath}/.folder`, '') + } + return paths + } + /** * Resolve the set of folder IDs that are effectively locked — locked directly * or via a locked ancestor folder. A workflow inside any of these folders is @@ -1814,10 +1835,18 @@ export class WorkspaceVFS { input: { workspaceId }, }) const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) + const folderPaths = await this.registerResourceFolders( + workspaceId, + 'knowledge_base', + 'knowledgebases' + ) for (const { knowledgeBase: kb, tagDefinitions } of knowledgeBases) { const safeName = sanitizeName(kb.name) - const prefix = `knowledgebases/${safeName}/` + const folderPath = kb.folderId ? folderPaths.get(kb.folderId) : undefined + const prefix = folderPath + ? `knowledgebases/${folderPath}/${safeName}/` + : `knowledgebases/${safeName}/` this.files.set( `${prefix}meta.json`, @@ -1912,12 +1941,17 @@ export class WorkspaceVFS { */ private async materializeTables(workspaceId: string): Promise { try { - const tables = await listTables(workspaceId) + const [tables, folderPaths] = await Promise.all([ + listTables(workspaceId), + this.registerResourceFolders(workspaceId, 'table', 'tables'), + ]) 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}` this.files.set( - `tables/${safeName}/meta.json`, + `${prefix}/meta.json`, serializeTableMeta({ id: table.id, name: table.name, diff --git a/apps/sim/lib/folders/application/resource-vfs.ts b/apps/sim/lib/folders/application/resource-vfs.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c997fa7f19d5505692b5757a935e207dd50d1a4 GIT binary patch literal 14692 zcmcIr>uwy!mCkQHMM*F+Gd9^{|D{Z6>0J>af=B|XO%M=**_`g-^s;A~>Fy!L&;<6^ zKEPt1Fi*1IcTSz^>ba1k#1>#ntgbqB?$=^5zp3ihwf8r#AM>FsbT% zT*bS_ygP#N!nsMEr){pt9JL?QcD9GrEH86g{psFT>%6QcVYjXKvG#cO?~Sm&Toz@f z*AeUr%69q&pCUY6Sv0L4YL2V=FI9cr+@zCLj@jwIE%Uk{oiE1Ue*40G>&|9rooDXP z$7fFWxF(;ci?*0FSg75!Oxyjsx^uM`N8^TVdRgYpp1WRDcV(Vk81h3jU&w=9P8vn+fgr=J0CTTNCv%GQt`0xJ%>7Z#|WlQj_T9o&~_VN~Am13ex z%o`yn0T@C$TDG$a6bshm3P2Z^IrKjVi1!~pxmjN42QCML?;RM1|KC)_qUB!j4nkD> zN(t>IulG&bF7=?XW7*_HHunm!e>-j5xKjfDJOZJ!+%;e)A(iPp1OZOmK`p0cbth*4 zry=I!7x@=4dmxh6Q!4GTm~x((?Vmz&4=}nSJ8-aPfWi+HR`jFklP$Sl++talFCJe! z5C14jZ*aHVVI z{k>u;18jv2x<4#oZs62qUW&ox;;!a-)1+54vOKv;+$*{971;eM+$a|JDj^@8o;MEg zXJ|Q?*9^&daR?`Yg)q{OaHd6*d+K`pKL1Q2sAD)kgxMTylFFX<79CX2cG$s_H{Mo7 zCR`Gm;}y7#Oxtpgd|l*s?<1kp4ZBVEU_B!4AyS*yJ*{z%Z#JrjiTTgd=EJg`RC7cl zz|=StahGxv7u~3w8@F5_Vu|jX8i^jrO3~(X2nahem$q)`9f!JZ0|PDEq)@0uwtSL>Q&<%}>K0%Pt7i7T%9OsRtPB1Wio7OfvTqgM z2`Ky`47o_!!9h(&m0K zapBgoN__$$vY=^gutu7X>5MwNbtm^CvLpQ}5Z?$dl)t9Nhc3NKi*{Qg5_=%HPGoGw zZ9drRVK4$l3Y3}yNx|T^sn0Op+|!2*=e$zL8D=xX7#}L z<3)R0H1LEb-Lt&)&f$$n2iX%+o}r!p{qNmNe;}EqjjfMNQ1&g&vhYn9tpZ-qwH+b> z1F?w+6~48*OhSCAdF3lvdRMUXqf|$ME!(WVMdNIRaBv%uQxxQi2oOfwc;{@K9+A2b4D5i;Ki-MVf0=1$}E!gF}t|)xrT& zW$K`*cT(nyD@gkA@X&p48=SNIlbdBT8`6^WkK(FHELVJXrygO@txE=nwC^LeXud>B zQ!FOs5}plNBvMB#R10bnyCo)oaNY+V@xJglOB7`ILrZWdM(@1>m~&Pj5_^^X`J!3Y z-m?$^P1AYUGrhxegJc4&{+sGkcSJz75rlwOEinxDu?eT@=aaHRDdk%S}*^N}4Xi}*LiECkMJWUac zvy0IxrVvl$NsXTy^3YAn*I*F?N? zKV|}U^X3>wM|D#nlOdyE?ytL_ehdhBhY)@H?$G^UvehUUG66*Ul`yW>(LrmyY7|M=mim5y4i*i>ejBv&vRl|-u=43NM>`lPcv?2Ot+q3?7ETt^8t$@QPLM62 zxr2ZC4c-VPqfnZYW;sD%Z`?1xxR-uO8Q3wD+x;i8+JBdH6U-lgds9fO9dw(Y^J6hv z%(<>bv>+}xecaJyIAnC30)CC|5GYBB0B+GB?u@jkJK*(*-?7-~5*IYqbbpH|q_kV_ z&v)TvGhY#I`#^?&i6V{C$SP`m1tok)AA^Y&8lJwsZHhtHnzsJ@Ujw!FNaz4r--yG>zm|LLG z^N>+WW53=!$;gGs-y0+7?_17fPF3}?Gs(T&A-4a5YI(I;^?GY_PoPIf^oGIfplZPt zYbC|(+F#m8>Z6&Iq}ecPG@jW|V^900&Zm$*8*b?Oi-To;NK6nr8Vn7%o$$Z`+E2zL zkAgvjB8ry^b4-?2>(1PwV34P;6JdiE6`8w6bE`HeJpSWKoUkkUq&lbB{vs1H2_X7szVJR-y&jiK+rOLH?FFNv_N8n8EAx{7jsORXfGx1FBrn3aAWj@l)gnj5)FCGh}lT+cR@smgc6%@BVH>=Ov%`#B=;kv2Mk6M zHNPV)@d!MaxIYz~VrsBte#_Z!GnN#QZ#H_<-<1Xzc(iB`ycr*Si|YgfwBNqLrPm>% zeQk@2Z$g3~(OC5YZ`Ar?FFuOdZBUJkiL?jVIlOE@AlJ4r| z%0YN}3)t&W9eGB?EHp1AI}V=Ou{r5bfAFc=cBLNz zIQJy%^CvJfdHK|6i;Q0yB34~LDbp^^bAyN*E3H&X><59{ySIbArUg{ida6D@PYod^bB+Vr5;=StGpaxq(PnR7rN#Jp!kjJrT? zO}KbPu@{{p4(bN`IvPG2QR{JJxRj1AAwpocI!rGSOS-7WFVBtk7u5s)hKaIJwkmEdL=SeX_~TR)@S zVwWk%$YX7R8x(0n7$9m42An)>qBgv#d>$SMG4mZ-%Zv$;=$Fx&h?RKtDAQ%w0 zm^Zzk4Nd0N2P*>3?Z8<2pvCXfjtUGaTo4cA)i9hKzNbwvfP`k;mY_;^L$y#Ki;9-8<5uo86HNr(7 z4%{w7z*wYTG5O;I7e3v#r&MBBD5B~dGl~XJy^uH~60&lytL4>9(qJj=Gkf_gZ(96Q z;FV%f&z^Rrpr!!7eKAPy@K2P2JHB>M4{v(8&*3)k8@>LNEbcl1Ks3pDtwqH9C3Z{>sI zH)mjsm|Uv% zMUP>7Z%cmN=wNpRT)!eVcq>^YJE(|Z3fMFO-_AGUeJ|9}jl)Cq26Dp%`@dn@Hi9MU zGkUYPeZ+mVfi`9zqh2n0Hy7@^&wubvli6UD~q-vP}hY~>1x(=fLO)f zzFDAH^aEhijP4CB!xh;i)`bR7)24VQiu7px%|n=b$=<|Yx9$8p`Mn^t33|J?!tgd| Ih{6W{AA}y(lmGw# literal 0 HcmV?d00001 diff --git a/apps/sim/lib/knowledge/application/knowledge-vfs.ts b/apps/sim/lib/knowledge/application/knowledge-vfs.ts index f7fbff3e8d7..4c4e5243da9 100644 --- a/apps/sim/lib/knowledge/application/knowledge-vfs.ts +++ b/apps/sim/lib/knowledge/application/knowledge-vfs.ts @@ -1,6 +1,14 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { type KnowledgeWorkspaceContext, @@ -17,6 +25,29 @@ import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' interface KnowledgeVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const knowledgeVfsAdapter: FolderedResourceAdapter = { + resourceType: 'knowledge_base', + rootSegment: 'knowledgebases', + label: 'knowledge base', + async listRows(workspaceId) { + const { data: rows } = await getWorkspaceKnowledgeBases(workspaceId, 'active', {}) + return rows.map((kb) => ({ id: kb.id, name: kb.name, folderId: kb.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await updateKnowledgeBase(row.id, { folderId }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + }, + async renameRow(row, newName, workspaceId) { + const updated = await updateKnowledgeBase(row.id, { name: newName }, generateRequestId(), { + assertedWorkspaceId: workspaceId, + }) + return { id: updated.id, name: updated.name } + }, } export interface RenameKnowledgeBaseByVfsPathInput extends KnowledgeVfsReferenceInput { @@ -27,8 +58,27 @@ export type DeleteKnowledgeBaseByVfsPathInput = KnowledgeVfsReferenceInput async function resolveKnowledgeBaseByVfsName( context: KnowledgeWorkspaceContext, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments( + knowledgeVfsAdapter, + context.workspaceId, + sourceSegments + ) + const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { + search: row.name, + }) + const match = rows.find((kb) => kb.id === row.id) + if (!match) { + throw new OrchestrationError( + 'not_found', + `Knowledge base not found at knowledgebases/${sourceSegments.join('/')}` + ) + } + return match + } const { data: rows } = await getWorkspaceKnowledgeBases(context.workspaceId, 'active', { search: sourceName, }) @@ -54,7 +104,11 @@ export const renameKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: RenameKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) const updated = await updateKnowledgeBase( knowledgeBase.id, { name: input.newName }, @@ -83,7 +137,11 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ resolveContext: ({ input }: { input: DeleteKnowledgeBaseByVfsPathInput }) => resolveKnowledgeWorkspaceContext(input), async execute({ input, context }) { - const knowledgeBase = await resolveKnowledgeBaseByVfsName(context, input.sourceName) + const knowledgeBase = await resolveKnowledgeBaseByVfsName( + context, + input.sourceName, + input.sourceSegments + ) await deleteKnowledgeBase(knowledgeBase.id, generateRequestId(), { assertedWorkspaceId: context.workspaceId, }) @@ -103,3 +161,93 @@ export const deleteKnowledgeBaseByVfsPath = defineAuthorizedKnowledgeUseCase({ metadata: { source: 'copilot_vfs', knowledgeBaseName: result.name }, }), }) + +export interface KnowledgeVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferKnowledgeVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under knowledgebases/ — folder invariants live in lib/folders. */ +export const createKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Created knowledge base folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** mv under knowledgebases/: rows into folders, folder moves/renames, leaf renames. */ +export const transferKnowledgeVfsItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferKnowledgeVfsItemsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Moved knowledge base VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) + +/** rm of knowledgebases/ folder paths — recursive via the shared cascade. */ +export const deleteKnowledgeVfsFolders = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.manageVfsFolders, + resolveContext: ({ input }: { input: KnowledgeVfsPathsInput }) => + resolveKnowledgeWorkspaceContext(input), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(knowledgeVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: result.workspaceId, + resourceName: 'knowledgebases', + description: 'Deleted knowledge base folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), +}) diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 32847a90a73..cc54bcbd650 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -18,6 +18,8 @@ describe('knowledge operation registry', () => { 'knowledge.delete', 'knowledge.bulk_delete', 'knowledge.vfs.rename', + 'knowledge.vfs.move', + 'knowledge.vfs.folders.manage', 'knowledge.vfs.delete', 'knowledge.search', 'knowledge.folders.list', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index c0181d2f8e9..318aecdbac1 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -77,6 +77,18 @@ export const knowledgeOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'knowledge.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), + manageVfsFolders: defineWorkspaceOperation({ + id: 'knowledge.vfs.folders.manage', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'knowledge.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f7..18633f87554 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -86,6 +86,12 @@ export const tableOperations = { workspaceApiKey: 'deny', ...COPILOT_PRINCIPAL_POLICY, }), + moveByVfsPath: defineWorkspaceOperation({ + id: 'tables.vfs.move', + minimumRole: 'write', + workspaceApiKey: 'deny', + ...COPILOT_PRINCIPAL_POLICY, + }), deleteByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.delete', minimumRole: 'write', diff --git a/apps/sim/lib/table/application/table-vfs.ts b/apps/sim/lib/table/application/table-vfs.ts index cba5c91866c..2e61a3b0578 100644 --- a/apps/sim/lib/table/application/table-vfs.ts +++ b/apps/sim/lib/table/application/table-vfs.ts @@ -1,16 +1,52 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { + createResourceVfsFolders, + deleteResourceVfsFolders, + type FolderedResourceAdapter, + resolveResourceRowBySegments, + transferResourceVfsItems, +} from '@/lib/folders/application/resource-vfs' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveTableWorkspaceContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' -import { deleteTable, findActiveTablesByExactName, renameTable } from '@/lib/table/service' +import { + deleteTable, + findActiveTablesByExactName, + listTables, + moveTableToFolder, + renameTable, +} from '@/lib/table/service' import type { TableDefinition } from '@/lib/table/types' interface TableVfsReferenceInput { workspaceId: string sourceName: string + /** Folder segments + leaf name; when present the nested-aware resolver is used. */ + sourceSegments?: string[] +} + +const tableVfsAdapter: FolderedResourceAdapter = { + resourceType: 'table', + rootSegment: 'tables', + label: 'table', + async listRows(workspaceId) { + const tables = await listTables(workspaceId) + return tables.map((t) => ({ id: t.id, name: t.name, folderId: t.folderId ?? null })) + }, + async moveRow(row, folderId, workspaceId) { + await moveTableToFolder(row.id, workspaceId, folderId, generateRequestId()) + }, + async renameRow(row, newName, workspaceId) { + const renamed = await renameTable(row.id, newName, generateRequestId(), { + expectedWorkspaceId: workspaceId, + skipNotify: true, + }) + return { id: renamed.id, name: renamed.name } + }, } export interface RenameTableByVfsPathInput extends TableVfsReferenceInput { @@ -21,8 +57,20 @@ export type DeleteTableByVfsPathInput = TableVfsReferenceInput async function resolveTableByVfsName( workspaceId: string, - sourceName: string + sourceName: string, + sourceSegments?: string[] ): Promise { + if (sourceSegments && sourceSegments.length > 1) { + const row = await resolveResourceRowBySegments(tableVfsAdapter, workspaceId, sourceSegments) + const matches = await findActiveTablesByExactName(workspaceId, row.name) + const table = matches.find((t) => t.id === row.id) + if (!table) + throw new OrchestrationError( + 'not_found', + `Table not found at tables/${sourceSegments.join('/')}` + ) + return table + } const matches = await findActiveTablesByExactName(workspaceId, sourceName) if (matches.length > 1) { throw new OrchestrationError('conflict', `Table path is ambiguous: tables/${sourceName}`) @@ -37,7 +85,11 @@ export const renameTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: RenameTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const renamed = await renameTable(table.id, input.newName, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -65,7 +117,11 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: DeleteTableByVfsPathInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ input, context }) { - const table = await resolveTableByVfsName(context.workspaceId, input.sourceName) + const table = await resolveTableByVfsName( + context.workspaceId, + input.sourceName, + input.sourceSegments + ) const { archived } = await deleteTable(table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -89,3 +145,96 @@ export const deleteTableByVfsPath = defineAuthorizedTableUseCase({ }), afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), }) + +export interface TableVfsPathsInput { + workspaceId: string + paths: Array<{ source: string; segments: string[] }> +} + +export interface TransferTableVfsItemsInput { + workspaceId: string + sources: Array<{ source: string; segments: string[] }> + destination: { segments: string[]; trailingSlash: boolean } +} + +/** mkdir -p under tables/ — folder invariants live in lib/folders. */ +export const createTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.createFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await createResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Created table folders', + metadata: { op: 'vfs_mkdir', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** mv under tables/: rows into folders, folder moves/renames, leaf renames. */ +export const transferTableVfsItems = defineAuthorizedTableUseCase({ + operation: tableOperations.moveByVfsPath, + resolveContext: ({ input }: { input: TransferTableVfsItemsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await transferResourceVfsItems(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + sources: input.sources, + destination: input.destination, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Moved table VFS items', + metadata: { op: 'vfs_mv', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) + +/** rm of tables/ folder paths — recursive via the shared cascade. */ +export const deleteTableVfsFolders = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteFolder, + resolveContext: ({ input }: { input: TableVfsPathsInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const outcomes = await deleteResourceVfsFolders(tableVfsAdapter, { + workspaceId: context.workspaceId, + userId, + paths: input.paths, + }) + return { outcomes, workspaceId: context.workspaceId } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.workspaceId, + resourceName: 'tables', + description: 'Deleted table folders', + metadata: { op: 'vfs_rm_folder', count: result.outcomes.length, source: 'copilot_vfs' }, + }), + afterSuccess: ({ context }) => notifyWorkspaceTablesChanged(context.workspaceId), +}) From a721895b8eee8e54e1716d87aae6a352a209cd06 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:53:59 -0700 Subject: [PATCH 007/135] =?UTF-8?q?feat(platform):=20platform=20subagent?= =?UTF-8?q?=20support=20=E2=80=94=20docs=20corpus=20VFS,=20search=5Fdocs,?= =?UTF-8?q?=20account=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash of the feat/platform-agent branch (sim side): mounts the Sim docs corpus in the copilot VFS, wires search_docs and retires the legacy docs search tools, and syncs the generated tool catalog and trace contracts for the platform subagent. --- .github/workflows/test-build.yml | 3 + .../content/docs/en/platform/permissions.mdx | 5 +- .../app/api/mothership/execute/route.test.ts | 10 +- apps/sim/app/api/mothership/execute/route.ts | 3 +- .../home/components/message-content/utils.ts | 1 + .../app/workspace/[workspaceId]/home/types.ts | 1 + .../components/group-detail.tsx | 148 +------ .../utils/permission-check.test.ts | 122 ++++++ .../access-control/utils/permission-check.ts | 96 ++++- apps/sim/lib/api/contracts/organization.ts | 4 +- apps/sim/lib/api/contracts/primitives.test.ts | 11 + apps/sim/lib/api/contracts/primitives.ts | 6 + apps/sim/lib/api/contracts/workspaces.test.ts | 32 ++ apps/sim/lib/api/contracts/workspaces.ts | 8 +- .../core/account-billing-snapshot.test.ts | 100 +++++ .../billing/core/account-billing-snapshot.ts | 58 +++ apps/sim/lib/billing/core/usage.ts | 48 ++- .../execute-platform-context-use-case.ts | 40 ++ .../auth/application-delegation.test.ts | 22 + .../copilot/auth/application-delegation.ts | 26 ++ apps/sim/lib/copilot/chat/post.test.ts | 6 +- apps/sim/lib/copilot/chat/post.ts | 18 +- .../lib/copilot/chat/process-contents.test.ts | 121 ++++++ apps/sim/lib/copilot/chat/process-contents.ts | 35 +- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 235 +++++++++++ apps/sim/lib/copilot/docs/docs-corpus.ts | 251 +++++++++++ apps/sim/lib/copilot/docs/docs-path.test.ts | 35 ++ apps/sim/lib/copilot/docs/docs-path.ts | 44 ++ apps/sim/lib/copilot/docs/docs-search.test.ts | 292 +++++++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 207 ++++++++++ .../lib/copilot/generated/docs-manifest.ts | 388 ++++++++++++++++++ .../lib/copilot/generated/tool-catalog-v1.ts | 83 ++-- .../lib/copilot/generated/tool-schemas-v1.ts | 51 ++- .../lib/copilot/generated/vfs-snapshot-v1.ts | 14 - .../request/lifecycle/headless.test.ts | 18 + .../lib/copilot/request/lifecycle/headless.ts | 1 + .../lib/copilot/request/lifecycle/run.test.ts | 42 ++ apps/sim/lib/copilot/request/lifecycle/run.ts | 2 + .../tool-executor/register-handlers.ts | 9 +- apps/sim/lib/copilot/tool-executor/types.ts | 2 + .../copilot/tools/client/store-utils.test.ts | 20 + .../lib/copilot/tools/client/store-utils.ts | 17 + .../copilot/tools/handlers/account.test.ts | 107 +++++ .../sim/lib/copilot/tools/handlers/account.ts | 27 ++ .../tools/handlers/enterprise-context.test.ts | 367 +++++++++++++++++ .../tools/handlers/enterprise-context.ts | 34 ++ .../tools/handlers/platform-actions.ts | 118 ------ .../lib/copilot/tools/handlers/platform.ts | 9 - .../lib/copilot/tools/handlers/vfs.test.ts | 140 ++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 97 ++++- .../tools/handlers/workflow/queries.test.ts | 52 ++- .../tools/handlers/workflow/queries.ts | 5 +- .../server/docs/search-docs-dispatch.test.ts | 39 ++ .../tools/server/docs/search-docs.test.ts | 152 +++++++ .../copilot/tools/server/docs/search-docs.ts | 79 ++++ .../server/docs/search-documentation.test.ts | 56 --- .../tools/server/docs/search-documentation.ts | 60 --- apps/sim/lib/copilot/tools/server/router.ts | 4 +- .../lib/copilot/tools/tool-display.test.ts | 19 + apps/sim/lib/copilot/tools/tool-display.ts | 11 +- .../lib/organizations/settings-access.test.ts | 8 + apps/sim/lib/organizations/settings-access.ts | 5 +- .../lib/permission-groups/features.test.ts | 97 +++++ apps/sim/lib/permission-groups/features.ts | 228 ++++++++++ .../application/authorization.ts | 12 + .../platform-context/application/context.ts | 14 + .../application/operations.ts | 24 ++ .../platform-context-use-cases.test.ts | 221 ++++++++++ .../application/read-account-billing.ts | 22 + .../application/read-enterprise-context.ts | 88 ++++ apps/sim/lib/workspaces/host-context.test.ts | 3 + apps/sim/lib/workspaces/host-context.ts | 3 +- package.json | 2 + scripts/sync-docs-manifest.ts | 113 +++++ 74 files changed, 4338 insertions(+), 513 deletions(-) create mode 100644 apps/sim/lib/api/contracts/workspaces.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.test.ts create mode 100644 apps/sim/lib/billing/core/account-billing-snapshot.ts create mode 100644 apps/sim/lib/copilot/application/execute-platform-context-use-case.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.ts create mode 100644 apps/sim/lib/copilot/docs/docs-path.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-path.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.ts create mode 100644 apps/sim/lib/copilot/generated/docs-manifest.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/account.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/account.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform-actions.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.ts create mode 100644 apps/sim/lib/permission-groups/features.test.ts create mode 100644 apps/sim/lib/permission-groups/features.ts create mode 100644 apps/sim/lib/platform-context/application/authorization.ts create mode 100644 apps/sim/lib/platform-context/application/context.ts create mode 100644 apps/sim/lib/platform-context/application/operations.ts create mode 100644 apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts create mode 100644 apps/sim/lib/platform-context/application/read-account-billing.ts create mode 100644 apps/sim/lib/platform-context/application/read-enterprise-context.ts create mode 100644 scripts/sync-docs-manifest.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..d5aa22c1ac2 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: Repo audits run: bun run check:audits + - name: Verify docs manifest is in sync + run: bun run docs-manifest:check + - name: Migration safety (zero-downtime) audit run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/apps/docs/content/docs/en/platform/permissions.mdx b/apps/docs/content/docs/en/platform/permissions.mdx index b5f1969c240..2f45f5c1a42 100644 --- a/apps/docs/content/docs/en/platform/permissions.mdx +++ b/apps/docs/content/docs/en/platform/permissions.mdx @@ -126,7 +126,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Read users can do, plus: - Create, edit, and delete workflows -- Run and deploy workflows +- Run workflows - Add, edit, and delete workspace environment variables - Use all available tools and integrations - Collaborate in real-time on workflow editing @@ -140,6 +140,7 @@ Here's a detailed breakdown of what users can do with each permission level: **What they can do:** - Everything Write users can do, plus: +- Deploy workflows - Invite new users to the workspace with any permission level - Remove users from the workspace - Manage workspace settings and integrations @@ -254,4 +255,4 @@ import { FAQ } from '@/components/ui/faq' { question: "Who can manage a workspace's credentials and secrets?", answer: "Workspace Admins are automatically Credential Admins of the workspace's shared credentials — OAuth connections, service accounts, and workspace environment variables — so they can use, edit, delete, and share them, and run workflows that rely on them. Organization Owners and Admins get this too because they are workspace Admins everywhere. Read and Write members get use-only access to shared credentials unless they are explicitly made a Credential Admin. Personal environment variables are never shared; they stay private to their owner." }, { question: "What are permission groups and how do they work?", answer: "Permission groups are an Enterprise access control feature that lets organization owners and admins define granular restrictions beyond the standard Read/Write/Admin roles. The organization's default group is org-wide; every other group targets specific workspaces and, by default, governs all members of those workspaces (including external members) — add members to restrict it to specific people. A user is governed by one group per workspace: a group they're an explicit member of takes precedence over an all-members group (one with no members) on that workspace, which takes precedence over the organization's default group. A permission group can hide UI sections (like trace spans, knowledge base, API keys, or deployment options), disable features (MCP tools, custom tools, skills, invitations), and restrict which integrations and model providers its members can access. Only one group per organization can be the default; it ignores members and governs everyone not covered by a workspace group, including external members. Restrictions are enforced based on the organization that owns the workflow's workspace, not on which workspace you're currently viewing." }, { question: "How should I set up permissions for a new team member?", answer: "Start with the lowest permission level they need. Invite them with Read workspace access if they only need visibility, Write if they need to create and run workflows, or Admin if they need to manage the workspace and its users, and leave Membership on Member. For clients, partners, and contractors, choose External so they collaborate without joining your organization or using a seat — this requires them to already be on a paid Sim plan, either their own Pro or Max subscription or another organization that seats them." }, -]} /> \ No newline at end of file +]} /> diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 007f9424f1b..43893921a35 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -237,8 +237,13 @@ describe('mothership private trace provenance transport', () => { 'user-1', 'secret-value __var_FOREIGN', 'workspace-1', - 'chat-1' + 'chat-1', + expect.any(Object) ) + + const contextRegistry = mockProcessContextsServer.mock.calls.at(-1)?.[5] + const lifecycleOptions = mockRunHeadlessCopilotLifecycle.mock.calls.at(-1)?.[1] + expect(contextRegistry).toBe(lifecycleOptions.environmentContext?.resolvedSecretTraceRegistry) }) it('keeps context routing and display inputs raw until the lifecycle boundary', async () => { @@ -290,7 +295,8 @@ describe('mothership private trace provenance transport', () => { 'user-1', 'hello', 'workspace-1', - 'chat-1' + 'chat-1', + expect.any(Object) ) }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 39f061a92d3..e01f727f591 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -244,7 +244,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { userId, lastUserMessage, workspaceId, - effectiveChatId + effectiveChatId, + activeResolvedSecretTraceRegistry ).catch((error) => { reqLogger.warn('Failed to resolve agent contexts for execution', { error: toError(error).message, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 8079fc40de6..4ccbf00f5a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -64,6 +64,7 @@ const TOOL_ICONS: Record = { research: Search, scout: Search, search: Search, + platform: Library, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index e6d21c27765..cf2a46b1b7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -189,6 +189,7 @@ export const SUBAGENT_LABELS: Record = { custom_tool: 'Custom Tool Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index f51c843ff60..a7961ba8949 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -30,6 +30,7 @@ import { useQueryState } from 'nuqs' import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { PLATFORM_CATEGORY_ORDER, PLATFORM_FEATURES } from '@/lib/permission-groups/features' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { @@ -174,151 +175,6 @@ function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFi ) } -/** Render order for the platform-feature category sections; unlisted ones follow. */ -const PLATFORM_CATEGORY_ORDER = [ - 'Sidebar', - 'Deploy Tabs', - 'Chat', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - 'Files', -] - -const PLATFORM_FEATURES = [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - // Chat and Files get a category of their own so their nested auth-mode - // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. - { - id: 'hide-deploy-chatbot', - label: 'Deployment', - category: 'Chat', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chat deployment option.', - }, - { - id: 'disable-public-file-sharing', - label: 'Public Sharing', - category: 'Files', - configKey: 'disablePublicFileSharing' as const, - hint: 'Disable public file-share links.', - }, -] - interface OrganizationMemberOption { userId: string user: { @@ -954,7 +810,7 @@ export function GroupDetail({ }, [searchedPlatformFeatures, statusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index 8a9d502c80c..b5eaa610496 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -84,6 +84,8 @@ import { ModelNotAllowedError, ProviderNotAllowedError, PublicFileSharingNotAllowedError, + resolveUserAccessControlContext, + resolveVerifiedUserAccessControlContext, SkillsNotAllowedError, ToolNotAllowedError, validateBlockType, @@ -229,6 +231,126 @@ describe('getUserPermissionConfig (org + entitlement gating)', () => { }) }) +describe('resolveUserAccessControlContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + }) + + it('describes a personal workspace without changing the config-only result', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ organizationId: null }) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + await expect(getUserPermissionConfig('user-123', 'workspace-1')).resolves.toBeNull() + }) + + it('returns the explicit governing group and its effective config', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-explicit', + name: 'Engineering', + config: { disableMcpTools: true }, + isMember: true, + hasMembers: true, + }, + ]) + + await expect(resolveUserAccessControlContext('user-123', 'workspace-1')).resolves.toEqual({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-explicit', + name: 'Engineering', + resolution: 'explicit-member', + }, + config: expect.objectContaining({ disableMcpTools: true }), + }) + }) + + it('identifies an all-members governing group', async () => { + setEnterpriseOrgWorkspace() + queueGroupResolution([ + { + id: 'group-all-members', + name: 'All workspace members', + config: { disableCustomTools: true }, + isMember: false, + hasMembers: false, + }, + ]) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-all-members', + name: 'All workspace members', + resolution: 'all-members', + }) + }) + + it('uses a verified workspace organization without loading the workspace again', async () => { + mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true) + queueGroupResolution([ + { + id: 'group-verified', + name: 'Verified group', + config: { disableSkills: true }, + isMember: true, + hasMembers: true, + }, + ]) + + const context = await resolveVerifiedUserAccessControlContext( + 'user-123', + 'workspace-1', + 'org-verified' + ) + + expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled() + expect(mockIsOrganizationOnEnterprisePlan).toHaveBeenCalledWith('org-verified') + expect(context).toMatchObject({ + organizationId: 'org-verified', + entitled: true, + permissionGroup: { + id: 'group-verified', + resolution: 'explicit-member', + }, + config: { disableSkills: true }, + }) + }) + + it('identifies the default group and preserves the environment allowlist', async () => { + setEnterpriseOrgWorkspace() + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) + queueGroupResolution( + [], + [ + { + id: 'group-default', + name: 'Organization default', + config: { allowedIntegrations: ['slack', 'github'] }, + }, + ] + ) + + const context = await resolveUserAccessControlContext('user-123', 'workspace-1') + + expect(context.permissionGroup).toEqual({ + id: 'group-default', + name: 'Organization default', + resolution: 'default', + }) + expect(context.config?.allowedIntegrations).toEqual(['slack']) + }) +}) + describe('getUserPermissionConfig (workspace-group precedence)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index d83b16e8f62..0afd24ed462 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -141,9 +141,30 @@ function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGrou export interface ResolvedPermissionGroup { permissionGroupId: string groupName: string + resolution: 'explicit-member' | 'all-members' | 'default' config: PermissionGroupConfig } +export interface UserAccessControlContext { + organizationId: string | null + entitled: boolean + permissionGroup: { + id: string + name: string + resolution: ResolvedPermissionGroup['resolution'] + } | null + config: PermissionGroupConfig | null +} + +function inactiveUserAccessControlContext(organizationId: string | null): UserAccessControlContext { + return { + organizationId, + entitled: false, + permissionGroup: null, + config: mergeEnvAllowlist(null), + } +} + /** The organization's single default group (`isDefault`), or `null`. */ async function resolveDefaultGroup( organizationId: string @@ -167,6 +188,7 @@ async function resolveDefaultGroup( return { permissionGroupId: defaultGroup.id, groupName: defaultGroup.name, + resolution: 'default', config: parsePermissionGroupConfig(defaultGroup.config), } } @@ -222,12 +244,14 @@ export async function resolveWorkspaceGroup( ) .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) - const winner = rows.find((row) => row.isMember) ?? rows.find((row) => !row.hasMembers) + const explicitMemberGroup = rows.find((row) => row.isMember) + const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) if (winner) { return { permissionGroupId: winner.id, groupName: winner.name, + resolution: explicitMemberGroup ? 'explicit-member' : 'all-members', config: parsePermissionGroupConfig(winner.config), } } @@ -246,26 +270,70 @@ export async function resolveWorkspaceGroup( * The env-level integration allowlist is always merged last so self-hosted * deployments can constrain integrations without touching the DB. */ -export async function getUserPermissionConfig( +async function resolveUserAccessControlContextForOrganization( userId: string, - workspaceId: string -): Promise { - if (!isHosted && !isAccessControlEnabled) { - return mergeEnvAllowlist(null) + workspaceId: string, + organizationId: string | null +): Promise { + if (!organizationId) return inactiveUserAccessControlContext(null) + + const isEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!isEnterprise) { + return inactiveUserAccessControlContext(organizationId) } - const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) - if (!ws?.organizationId) { - return mergeEnvAllowlist(null) + const resolved = await resolveWorkspaceGroup(userId, organizationId, workspaceId) + return { + organizationId, + entitled: true, + permissionGroup: resolved + ? { + id: resolved.permissionGroupId, + name: resolved.groupName, + resolution: resolved.resolution, + } + : null, + config: mergeEnvAllowlist(resolved?.config ?? null), } +} - const isEnterprise = await isOrganizationOnEnterprisePlan(ws.organizationId) - if (!isEnterprise) { - return mergeEnvAllowlist(null) +/** + * Resolves Access Control from an organization ID obtained from an already + * access-checked workspace. This function does not independently authorize the + * user for the workspace; callers must establish that boundary first. + */ +export async function resolveVerifiedUserAccessControlContext( + userId: string, + workspaceId: string, + organizationId: string | null +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) } + return resolveUserAccessControlContextForOrganization(userId, workspaceId, organizationId) +} - const resolved = await resolveWorkspaceGroup(userId, ws.organizationId, workspaceId) - return mergeEnvAllowlist(resolved?.config ?? null) +export async function resolveUserAccessControlContext( + userId: string, + workspaceId: string +): Promise { + if (!isHosted && !isAccessControlEnabled) { + return inactiveUserAccessControlContext(null) + } + + const workspace = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + return resolveUserAccessControlContextForOrganization( + userId, + workspaceId, + workspace?.organizationId ?? null + ) +} + +export async function getUserPermissionConfig( + userId: string, + workspaceId: string +): Promise { + return (await resolveUserAccessControlContext(userId, workspaceId)).config } /** diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index bbd1fcf69ce..bf3415aae92 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + organizationRoleSchema, type PiiRedactionSettings, piiRedactionSettingsSchema, retentionOverridesSchema, @@ -15,9 +16,6 @@ const numericResponseSchema = z.preprocess((value) => { return Number.isFinite(parsed) ? parsed : value }, z.number()) -export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { - error: 'Invalid role', -}) export const organizationParamsSchema = z.object({ id: z.string().min(1), }) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 4e8a605a98f..7670830de49 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -6,6 +6,7 @@ import { customPatternSchema, isCanonicalBase64, organizationIdSchema, + organizationRoleSchema, piiStagePolicySchema, piiStagesSchema, privateSecretProvenanceBundleSchema, @@ -16,6 +17,16 @@ import { workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('organizationRoleSchema', () => { + it.each(['owner', 'admin', 'member'] as const)('accepts canonical role %s', (role) => { + expect(organizationRoleSchema.parse(role)).toBe(role) + }) + + it.each(['billing-owner', 'viewer', '', null, undefined])('rejects invalid role %j', (role) => { + expect(organizationRoleSchema.safeParse(role).success).toBe(false) + }) +}) + describe('workspaceFileNameSchema', () => { it('trims and accepts one bounded file name', () => { expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index c05dc4679fa..7de554762ba 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -231,6 +231,12 @@ export const workspaceFileNameSchema = z /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') +/** Canonical organization membership role shared across API resource families. */ +export const organizationRoleSchema = z.enum(['owner', 'admin', 'member'], { + error: 'Invalid role', +}) +export type OrganizationRole = z.output + /** Non-empty `workflowId` field with a stable, human-readable message. */ export const workflowIdSchema = requiredFieldSchema('Workflow ID is required') diff --git a/apps/sim/lib/api/contracts/workspaces.test.ts b/apps/sim/lib/api/contracts/workspaces.test.ts new file mode 100644 index 00000000000..8adfeed25e4 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspaces.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { workspaceHostContextSchema } from '@/lib/api/contracts/workspaces' + +const viewerSchema = workspaceHostContextSchema.shape.viewer +const viewer = { + permission: 'read' as const, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, +} + +describe('workspaceHostContextSchema organizationRole', () => { + it.each(['owner', 'admin', 'member'] as const)( + 'accepts canonical role %s', + (organizationRole) => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole }).success).toBe(true) + } + ) + + it('retains null and omission for rolling response compatibility', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: null }).success).toBe(true) + expect(viewerSchema.safeParse(viewer).success).toBe(true) + }) + + it('rejects non-canonical organization roles', () => { + expect(viewerSchema.safeParse({ ...viewer, organizationRole: 'billing-owner' }).success).toBe( + false + ) + }) +}) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 5210e774c75..9c32dc4d23e 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { nonEmptyIdSchema, requiredFieldSchema } from '@/lib/api/contracts/primitives' +import { + nonEmptyIdSchema, + organizationRoleSchema, + requiredFieldSchema, +} from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' export const workspaceScopeSchema = z.enum(['active', 'archived', 'all']) @@ -263,6 +267,8 @@ export const workspaceHostContextSchema = z.object({ permission: workspacePermissionSchema, isHostOrganizationMember: z.boolean(), isHostOrganizationAdmin: z.boolean(), + /** Optional for rolling compatibility with app versions that predate organization-role projection. */ + organizationRole: organizationRoleSchema.nullable().optional(), }), }) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.test.ts b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts new file mode 100644 index 00000000000..503d01834af --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + getResolvedUserUsageData: vi.fn(), + getCreditBalanceForEntity: vi.fn(), + isOrgScopedSubscription: vi.fn(), +})) + +vi.mock('@/lib/billing/core/usage', () => ({ + getResolvedUserUsageData: mocks.getResolvedUserUsageData, +})) + +vi.mock('@/lib/billing/credits/balance', () => ({ + getCreditBalanceForEntity: mocks.getCreditBalanceForEntity, +})) + +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + isOrgScopedSubscription: mocks.isOrgScopedSubscription, +})) + +import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' + +const usage = { + currentUsage: 18.5, + limit: 40, + percentUsed: 46.25, + isWarning: false, + isExceeded: false, + billingPeriodStart: new Date('2026-08-01T00:00:00Z'), + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + lastPeriodCost: 31, +} + +describe('getAccountBillingSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + }) + + it('reuses one resolved subscription for org scope, usage, limits, and credits', async () => { + const subscription = { + plan: 'team', + referenceId: 'org-1', + } + mocks.getResolvedUserUsageData.mockImplementation(async () => { + mocks.events.push('usage-and-subscription') + return { usage, subscription, personalCreditBalance: 4 } + }) + mocks.isOrgScopedSubscription.mockReturnValue(true) + mocks.getCreditBalanceForEntity.mockImplementation(async () => { + mocks.events.push('credits') + return 25 + }) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toEqual({ + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' }, + }) + expect(mocks.getResolvedUserUsageData).toHaveBeenCalledOnce() + expect(mocks.getCreditBalanceForEntity).toHaveBeenCalledWith( + 'organization', + 'org-1', + expect.anything() + ) + expect(mocks.events).toEqual(['usage-and-subscription', 'credits']) + }) + + it('preserves personal scope and clamps negative remaining usage to zero', async () => { + mocks.getResolvedUserUsageData.mockResolvedValue({ + usage: { ...usage, currentUsage: 45, isExceeded: true }, + subscription: { plan: 'pro', referenceId: 'user-1' }, + personalCreditBalance: 0, + }) + mocks.isOrgScopedSubscription.mockReturnValue(false) + mocks.getCreditBalanceForEntity.mockResolvedValue(0) + + await expect(getAccountBillingSnapshot('user-1')).resolves.toMatchObject({ + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: { remaining: 0, isExceeded: true }, + credits: { balance: 0, scope: 'user' }, + }) + expect(mocks.getCreditBalanceForEntity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/account-billing-snapshot.ts b/apps/sim/lib/billing/core/account-billing-snapshot.ts new file mode 100644 index 00000000000..a7591357aca --- /dev/null +++ b/apps/sim/lib/billing/core/account-billing-snapshot.ts @@ -0,0 +1,58 @@ +import { db } from '@sim/db' +import { getResolvedUserUsageData } from '@/lib/billing/core/usage' +import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance' +import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import type { DbClient } from '@/lib/db/types' + +export interface AccountBillingSnapshot { + plan: string + billingScope: 'user' | 'organization' + organizationId: string | null + usage: { + currentPeriodCost: number + limit: number + remaining: number + percentUsed: number + isExceeded: boolean + billingPeriodEnd: Date | null + } + credits: { + balance: number + scope: 'user' | 'organization' + } +} + +/** Resolves one coherent subscription, usage, limit, and credit snapshot for an account. */ +export async function getAccountBillingSnapshot( + userId: string, + executor: DbClient = db +): Promise { + const { usage, subscription, personalCreditBalance } = await getResolvedUserUsageData( + userId, + executor + ) + const organizationScoped = isOrgScopedSubscription(subscription, userId) && subscription !== null + const billingScope = organizationScoped ? 'organization' : 'user' + const billingEntityId = organizationScoped ? subscription.referenceId : userId + const creditBalance = organizationScoped + ? await getCreditBalanceForEntity('organization', billingEntityId, executor) + : personalCreditBalance + + return { + plan: subscription?.plan || 'free', + billingScope, + organizationId: organizationScoped ? subscription.referenceId : null, + usage: { + currentPeriodCost: usage.currentUsage, + limit: usage.limit, + remaining: Math.max(0, usage.limit - usage.currentUsage), + percentUsed: usage.percentUsed, + isExceeded: usage.isExceeded, + billingPeriodEnd: usage.billingPeriodEnd, + }, + credits: { + balance: creditBalance, + scope: billingScope, + }, + } +} diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index b68d1982623..0fe0e91009d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,7 +14,10 @@ import { } from '@/components/emails' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { + getHighestPrioritySubscription, + type HighestPrioritySubscription, +} from '@/lib/billing/core/plan' import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' import { computeDailyRefreshConsumed, @@ -190,13 +193,18 @@ export async function ensureUserStatsExists(userId: string): Promise { .onConflictDoNothing({ target: userStats.userId }) } -/** - * Get comprehensive usage data for a user - */ -export async function getUserUsageData( +export interface ResolvedUserUsageData { + usage: UsageData + subscription: HighestPrioritySubscription + /** The personal balance from the same user-stats row used to calculate usage. */ + personalCreditBalance: number +} + +/** Resolves comprehensive usage and the subscription that determined its billing scope. */ +export async function getResolvedUserUsageData( userId: string, executor: DbClient = db -): Promise { +): Promise { try { // Write — always on the primary regardless of executor routing. await ensureUserStatsExists(userId) @@ -332,14 +340,18 @@ export async function getUserUsageData( const isExceeded = effectiveUsage >= limit return { - currentUsage: effectiveUsage, - limit, - percentUsed, - isWarning, - isExceeded, - billingPeriodStart, - billingPeriodEnd, - lastPeriodCost, + usage: { + currentUsage: effectiveUsage, + limit, + percentUsed, + isWarning, + isExceeded, + billingPeriodStart, + billingPeriodEnd, + lastPeriodCost, + }, + subscription, + personalCreditBalance: toNumber(toDecimal(stats.creditBalance)), } } catch (error) { logger.error('Failed to get user usage data', { userId, error }) @@ -347,6 +359,14 @@ export async function getUserUsageData( } } +/** Get comprehensive usage data for a user. */ +export async function getUserUsageData( + userId: string, + executor: DbClient = db +): Promise { + return (await getResolvedUserUsageData(userId, executor)).usage +} + /** * Get usage limit information for a user */ diff --git a/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts new file mode 100644 index 00000000000..b891ea294ce --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-platform-context-use-case.ts @@ -0,0 +1,40 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + InteractiveCopilotExecutionRequiredError, + requireInteractiveCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { + type PlatformContextOperation, + platformContextOperations, +} from '@/lib/platform-context/application/operations' + +const executePlatformContextUseCase = createCopilotApplicationAdapter({ + domain: 'platform context', + delegation: { + audience: platformContextDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: platformContextOperations, +}) + +/** Enters a live platform-context operation only from a trusted interactive Copilot lifecycle. */ +export function executeCopilotPlatformContextUseCase( + context: CopilotExecutionContext | undefined, + useCase: OperationUseCase, + input: I +): Promise { + const trustedContext = requireInteractiveCopilotExecutionContext(context) + return executePlatformContextUseCase(trustedContext, useCase, input) +} + +/** Projects only actionable authorization failures into live platform-context tool output. */ +export function messageForCopilotPlatformContextError(error: unknown): string { + if (error instanceof InteractiveCopilotExecutionRequiredError) return error.message + return messageForCopilotApplicationError(error) +} diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/copilot/auth/application-delegation.test.ts index 1e0bdc5037c..655f685fc64 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createCopilotApplicationPrincipal, + requireInteractiveCopilotExecutionContext, requireTrustedCopilotExecutionContext, } from '@/lib/copilot/auth/application-delegation' @@ -61,4 +62,25 @@ describe('Copilot application delegation', () => { }, }) }) + + it.each([undefined, 'headless' as const])( + 'rejects non-interactive live platform context (%s)', + (copilotInteractionMode) => { + expect(() => + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode, + }) + ).toThrow('only in an interactive Copilot session') + } + ) + + it('accepts a server-classified interactive lifecycle', () => { + expect( + requireInteractiveCopilotExecutionContext({ + ...trustedContext, + copilotInteractionMode: 'interactive', + }) + ).toMatchObject({ copilotInteractionMode: 'interactive' }) + }) }) diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/copilot/auth/application-delegation.ts index 969bf37b325..7b095d8c7ba 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.ts +++ b/apps/sim/lib/copilot/auth/application-delegation.ts @@ -9,6 +9,7 @@ export interface CopilotExecutionContext { executionId?: string toolCallId?: string copilotToolExecution?: boolean + copilotInteractionMode?: 'interactive' | 'headless' } export interface TrustedCopilotExecutionContext extends CopilotExecutionContext { @@ -18,6 +19,17 @@ export interface TrustedCopilotExecutionContext extends CopilotExecutionContext copilotToolExecution: true } +export interface TrustedInteractiveCopilotExecutionContext extends TrustedCopilotExecutionContext { + copilotInteractionMode: 'interactive' +} + +export class InteractiveCopilotExecutionRequiredError extends Error { + constructor() { + super('Live platform context is available only in an interactive Copilot session.') + this.name = 'InteractiveCopilotExecutionRequiredError' + } +} + export type CopilotResourceScope = Pick< NonNullable, 'fileId' | 'tableId' @@ -74,9 +86,23 @@ export function requireTrustedCopilotExecutionContext( ...(context.executionId ? { executionId: context.executionId } : {}), toolCallId: context.toolCallId, copilotToolExecution: true, + ...(context.copilotInteractionMode + ? { copilotInteractionMode: context.copilotInteractionMode } + : {}), }) } +/** Restricts sensitive live platform reads to a server-classified interactive lifecycle. */ +export function requireInteractiveCopilotExecutionContext( + context: CopilotExecutionContext | undefined +): TrustedInteractiveCopilotExecutionContext { + const trustedContext = requireTrustedCopilotExecutionContext(context) + if (trustedContext.copilotInteractionMode !== 'interactive') { + throw new InteractiveCopilotExecutionRequiredError() + } + return trustedContext as TrustedInteractiveCopilotExecutionContext +} + /** Creates a bounded Copilot principal from an explicitly trusted server lifecycle. */ export function createTrustedCopilotPrincipal( input: CreateTrustedCopilotPrincipalInput, diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 4f8efd52029..f7f71a24733 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -384,7 +384,8 @@ describe('handleUnifiedChatPost', () => { 'user-1', 'Hello', 'ws-1', - expect.anything() + expect.anything(), + expect.any(ResolvedSecretTraceRegistry) ) }) @@ -448,7 +449,8 @@ describe('handleUnifiedChatPost', () => { 'user-1', 'Explain these selections', 'ws-1', - 'chat-1' + 'chat-1', + expect.any(ResolvedSecretTraceRegistry) ) }) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 0bde82a6c45..710ea9ad544 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -461,9 +461,19 @@ async function resolveAgentContexts(params: { message: string workspaceId?: string chatId?: string + resolvedSecretTraceRegistry?: ExecutionContext['resolvedSecretTraceRegistry'] requestId: string }): Promise> { - const { contexts, resourceAttachments, userId, message, workspaceId, chatId, requestId } = params + const { + contexts, + resourceAttachments, + userId, + message, + workspaceId, + chatId, + resolvedSecretTraceRegistry, + requestId, + } = params let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = [] @@ -474,7 +484,8 @@ async function resolveAgentContexts(params: { userId, message, workspaceId, - chatId + chatId, + resolvedSecretTraceRegistry ) } catch (error) { logger.error(`[${requestId}] Failed to process contexts`, error) @@ -1267,7 +1278,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { }), activeOtelRoot.context ) - const agentContextsPromise = executionContextPromise.then(() => { + const agentContextsPromise = executionContextPromise.then((executionContext) => { return withCopilotSpan( TraceSpan.CopilotChatResolveAgentContexts, { @@ -1282,6 +1293,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { message: body.message, workspaceId, chatId: actualChatId, + resolvedSecretTraceRegistry: executionContext.resolvedSecretTraceRegistry, requestId, }), activeOtelRoot.context diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 6570628e3bb..3e29c8b4046 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -10,6 +10,7 @@ import { MAX_TABLE_SELECTION_ROWS, } from '@/lib/copilot/chat/selection-context' import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ChatContext } from '@/stores/panel' const { @@ -25,6 +26,7 @@ const { readKnowledgeBase, getBlockVisibilityForCopilot, isIntegrationDeploymentAvailable, + searchDocsExecute, } = vi.hoisted(() => ({ discoverServerTools: vi.fn(), getBlock: vi.fn(), @@ -38,6 +40,7 @@ const { readKnowledgeBase: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), isIntegrationDeploymentAvailable: vi.fn(() => true), + searchDocsExecute: vi.fn(), })) vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry })) @@ -57,6 +60,9 @@ vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ readKnowledgeBase: { execute: readKnowledgeBase }, })) +vi.mock('@/lib/copilot/tools/server/docs/search-docs', () => ({ + searchDocsServerTool: { execute: searchDocsExecute }, +})) /** * Overrides the global `@sim/db` mock: the logs-context tests below need @@ -282,6 +288,121 @@ describe('processContextsServer - skill contexts', () => { }) }) +describe('processContextsServer - docs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes @Docs to an unscoped search_docs query', async () => { + const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry() + const results = [ + { + path: 'docs/workflows/loops.mdx', + url: 'https://docs.sim.ai/workflows/loops', + title: 'Loops', + content: 'Use a loop block to iterate.', + similarity: 0.9, + }, + ] + searchDocsExecute.mockResolvedValue({ results, query: 'how do loops work?', totalResults: 1 }) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs how do loops work?', + 'ws-1', + undefined, + resolvedSecretTraceRegistry + ) + + expect(searchDocsExecute).toHaveBeenCalledWith( + { query: 'how do loops work?' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: undefined, + resolvedSecretTraceRegistry, + } + ) + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify({ results }), + }, + ]) + }) + + it('preserves the search note when @Docs has no relevant matches', async () => { + const note = + 'No relevant matches. This does NOT mean the docs lack this topic. Rephrase the query.' + searchDocsExecute.mockResolvedValue({ results: [], query: 'new topic', totalResults: 0, note }) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs new topic', + 'ws-1', + undefined, + new ResolvedSecretTraceRegistry() + ) + + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify({ results: [], note }), + }, + ]) + }) + + it('uses the Docs label when the message only contains the mention', async () => { + searchDocsExecute.mockResolvedValue({ results: [], query: 'Docs', totalResults: 0 }) + + await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs', + 'ws-1', + 'chat-1', + new ResolvedSecretTraceRegistry() + ) + + expect(searchDocsExecute).toHaveBeenCalledWith( + { query: 'Docs' }, + expect.objectContaining({ workspaceId: 'ws-1', chatId: 'chat-1' }) + ) + }) + + it('preserves an explicit unavailable note when docs search fails', async () => { + searchDocsExecute.mockRejectedValue(new Error('embedding service unavailable')) + + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' }], + 'user-1', + '@Docs explain schedules', + 'ws-1', + 'chat-1', + new ResolvedSecretTraceRegistry() + ) + + expect(result).toEqual([ + { + type: 'docs', + tag: '@Docs', + content: JSON.stringify({ + results: [], + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + }), + }, + ]) + expect(mockProcessContentsLogger.error).toHaveBeenCalledWith( + 'Failed to process docs context', + expect.any(Error) + ) + }) +}) + describe('processContextsServer - MCP contexts', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 689eaed8a02..3ee1367159b 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -49,6 +49,7 @@ import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/rea import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' type AgentContextType = @@ -124,7 +125,8 @@ export async function processContextsServer( userId: string, userMessage?: string, currentWorkspaceId?: string, - chatId?: string + chatId?: string, + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry ): Promise { if (!Array.isArray(contexts) || contexts.length === 0) return [] const tasks = contexts.map(async (ctx) => { @@ -314,17 +316,36 @@ export async function processContextsServer( } if (ctx.kind === 'docs') { try { - const { searchDocumentationServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-documentation' + const { searchDocsServerTool } = await import( + '@/lib/copilot/tools/server/docs/search-docs' ) const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' - const query = sanitizeMessageForDocs(rawQuery, contexts) - const res = await searchDocumentationServerTool.execute({ query, topK: 10 }) - const content = JSON.stringify(res?.results || []) + const query = + sanitizeMessageForDocs(rawQuery, contexts) || ctx.label || 'Sim documentation' + const res = await searchDocsServerTool.execute( + { query }, + { + userId, + workspaceId: currentWorkspaceId, + chatId, + resolvedSecretTraceRegistry, + } + ) + const content = JSON.stringify({ + results: res?.results || [], + ...(res?.note ? { note: res.note } : {}), + }) return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } } catch (e) { logger.error('Failed to process docs context', e) - return null + return { + type: 'docs', + tag: ctx.label ? `@${ctx.label}` : '@', + content: JSON.stringify({ + results: [], + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + }), + } } } return null diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts new file mode 100644 index 00000000000..54764c35625 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSleep } = vi.hoisted(() => ({ + mockSleep: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@sim/utils/helpers', () => ({ + sleep: mockSleep, +})) + +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocs, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') + +function fetchResponse(status: number, content = '', headers: HeadersInit = {}) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + text: async () => content, + } +} + +describe('docs corpus scoping', () => { + it('recognizes docs paths', () => { + expect(isDocsPath('docs/workflows.mdx')).toBe(true) + expect(isDocsPath('docs')).toBe(true) + expect(isDocsPath('/docs/workflows.mdx')).toBe(true) + expect(isDocsPath('workflows.mdx')).toBe(false) + expect(isDocsPath('files/report.pdf')).toBe(false) + expect(isDocsPath('docsomething/x')).toBe(false) + expect(isDocsPath(undefined)).toBe(false) + }) + + it('is opt-in: only an explicit docs/ pattern can match', () => { + expect(couldMatchDocsScope('docs/**')).toBe(true) + expect(couldMatchDocsScope('docs/workflows/**')).toBe(true) + expect(couldMatchDocsScope('**')).toBe(false) + expect(couldMatchDocsScope('**/*.mdx')).toBe(false) + expect(couldMatchDocsScope('*')).toBe(false) + expect(couldMatchDocsScope(undefined)).toBe(false) + }) +}) + +describe('globDocs', () => { + it('lists the whole corpus under docs/**', () => { + const files = globDocs('docs/**') + expect(files.length).toBeGreaterThan(DOCS_MANIFEST.length) + expect(files).toContain('docs/workflows/blocks/agent.mdx') + expect(files).toContain('docs/workflows/blocks') + }) + + it('scopes to a section', () => { + const files = globDocs('docs/integrations/*.mdx') + expect(files).toContain('docs/integrations/gmail.mdx') + expect(files.every((path) => path.startsWith('docs/integrations/'))).toBe(true) + }) + + it('excludes academy and api-reference', () => { + expect(globDocs('docs/academy/**')).toEqual([]) + expect(globDocs('docs/api-reference/**')).toEqual([]) + }) + + it('maps section index pages onto their parent URL path', () => { + expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx']) + expect(globDocs('docs/workflows/index.mdx')).toEqual([]) + }) + + it('treats a trailing-slash pattern like the bare directory instead of matching nothing', () => { + expect(globDocs('docs/')).toEqual(['docs']) + expect(globDocs('docs/integrations/')).toEqual(['docs/integrations']) + }) +}) + +describe('readDocsPage', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + mockSleep.mockReset() + mockSleep.mockResolvedValue(undefined) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches the manifest path verbatim from the docs site', async () => { + expect(SAMPLE_PAGE).toBeDefined() + fetchMock.mockResolvedValue(fetchResponse(200, '# Agent\n\nbody')) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).toBe(`https://docs.sim.ai/${SAMPLE_PAGE}`) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) + }) + + it('rejects an unknown page without fetching', async () => { + await expect(readDocsPage('docs/not-a-real-page.mdx')).rejects.toThrow(DocsCorpusError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('points a directory read at glob', async () => { + await expect(readDocsPage('docs/workflows/blocks')).rejects.toThrow(/is a directory/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => { + fetchMock.mockResolvedValue(fetchResponse(502)) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('treats a network failure as retryable', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('recovers when a transient failure clears on retry', async () => { + fetchMock + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValue(fetchResponse(200, '# Agent\n\nbody')) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) + }) + + it('reports a page the site no longer serves as permanent, without retrying', async () => { + fetchMock.mockResolvedValue(fetchResponse(404)) + const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) + expect(error).toBeInstanceOf(DocsCorpusError) + expect(error.message).toMatch(/does not serve it/) + expect(error.message).toMatch(/retrying will not help/) + expect(error.message).not.toMatch(/could not be reached/) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('honors Retry-After while retrying a 429 response', async () => { + fetchMock.mockResolvedValue(fetchResponse(429, '', { 'Retry-After': '7' })) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(mockSleep).toHaveBeenNthCalledWith(1, 7_000) + expect(mockSleep).toHaveBeenNthCalledWith(2, 7_000) + }) + + it('treats 408 as retryable rather than a missing page', async () => { + fetchMock.mockResolvedValue(fetchResponse(408)) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('aborts an in-flight fetch without retrying', async () => { + const controller = new AbortController() + fetchMock.mockImplementation((_url: string, init: RequestInit) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + + const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + controller.abort(new Error('user stopped docs read')) + + await expect(request).rejects.toThrow('user stopped docs read') + expect(fetchMock).toHaveBeenCalledOnce() + expect(mockSleep).not.toHaveBeenCalled() + }) + + it('aborts retry backoff before starting another fetch', async () => { + const controller = new AbortController() + fetchMock.mockResolvedValue(fetchResponse(502)) + mockSleep.mockImplementationOnce(() => new Promise(() => {})) + + const request = readDocsPage(`docs/${SAMPLE_PAGE}`, controller.signal) + await vi.waitFor(() => expect(mockSleep).toHaveBeenCalledOnce()) + controller.abort(new Error('user stopped docs retry')) + + await expect(request).rejects.toThrow('user stopped docs retry') + expect(fetchMock).toHaveBeenCalledOnce() + }) +}) + +describe('grepDocs', () => { + const fetchMock = vi.fn() + const SECTION_DIR = 'docs/workflows/blocks' + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('greps exactly one page for a page path', async () => { + fetchMock.mockResolvedValue(fetchResponse(200, 'intro line\nsystemPrompt matters\ntail')) + + const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt') + + expect(fetchMock).toHaveBeenCalledOnce() + expect(matches).toEqual([ + { path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' }, + ]) + }) + + it('rejects a directory without fetching any pages', async () => { + await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow( + /grep must target one docs page/ + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a path that is neither a page nor a directory without fetching', async () => { + await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow(/not a docs page/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts new file mode 100644 index 00000000000..e8eca75761d --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -0,0 +1,251 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' + +const logger = createLogger('DocsCorpus') + +/** The public docs site the `docs/` tree is a lazy view of. */ +const DOCS_BASE_URL = 'https://docs.sim.ai' + +/** VFS prefix the docs corpus is mounted at. */ +const DOCS_PREFIX = 'docs/' + +/** Per-attempt budget — the site is CDN-cached and normally answers in well under a second. */ +const FETCH_ATTEMPT_TIMEOUT_MS = 3_000 +const FETCH_MAX_ATTEMPTS = 3 + +/** + * Thrown for expected, user-facing docs-corpus conditions (unknown page, + * directory path, site unreachable). The VFS handlers return the message as the + * tool error instead of logging an internal failure. + */ +export class DocsCorpusError extends Error { + constructor(message: string) { + super(message) + this.name = 'DocsCorpusError' + } +} + +/** + * Keys-only view of the corpus for glob: every manifest path under `docs/`, + * mapped to empty content. `ops.glob` matches keys and derives the virtual + * directories from them, so this never touches the network. + */ +const docsKeyView: Map = new Map( + DOCS_MANIFEST.map((path) => [`${DOCS_PREFIX}${path}`, '']) +) + +/** + * Normalize a docs path and make `docs/` equivalent to `docs`, avoiding empty + * glob results caused only by a trailing slash. + */ +export function normalizeDocsPath(path: string): string { + return path.trim().replace(/^\/+/, '').replace(/\/+$/, '') +} + +/** + * True when a read/grep `path` addresses the docs corpus. Deliberately not a + * `path is string` type predicate: the callers chain it ahead of the other + * namespace checks, and a predicate would narrow `path` to `never` in every + * later branch. + */ +export function isDocsPath(path: string | undefined): boolean { + if (!path) return false + const normalized = normalizeDocsPath(path) + return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) +} + +/** + * True when a glob `pattern` could match the docs corpus. Like `uploads/` and + * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly + * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never + * drags 300+ doc pages into the result. Same rule as {@link isDocsPath}; the + * separate name reads correctly at the glob call site. + */ +export function couldMatchDocsScope(pattern: string | undefined): boolean { + return isDocsPath(pattern) +} + +/** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ +export function globDocs(pattern: string): string[] { + return globPaths(docsKeyView, normalizeDocsPath(pattern)) +} + +/** True when `path` is a page in the docs tree. */ +export function isDocsPage(path: string): boolean { + return docsKeyView.has(normalizeDocsPath(path)) +} + +/** + * Map a `docs_embeddings.source_document` (the en-relative mdx file path) back to + * its `docs/` VFS path, applying the same index-page fold as the manifest + * generator. Returns null when the source has no live VFS path — an unmounted + * section (academy, api-reference) or a page deleted since the index was built. + */ +export function docsPathForSourceDocument(sourceDocument: string | null): string | null { + if (!sourceDocument) return null + const path = `${DOCS_PREFIX}${foldDocsIndexPath(sourceDocument.replace(/^\/+/, ''))}` + return docsKeyView.has(path) ? path : null +} + +/** True when `path` is a directory in the docs tree rather than a page. */ +export function isDocsDir(path: string): boolean { + const dir = `${normalizeDocsPath(path).replace(/\/+$/, '')}/` + if (dir === DOCS_PREFIX) return true + for (const key of docsKeyView.keys()) { + if (key.startsWith(dir)) return true + } + return false +} + +export interface DocsPage { + content: string + totalLines: number +} + +/** + * Fetch one docs page's raw markdown from the live site. The manifest path IS + * the URL path (`docs/workflows/blocks/agent.mdx` → + * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites + * to its raw-markdown route), so no mapping table is needed. Transient failures + * (5xx, 429, network error, timeout) are retried with jittered backoff before + * being reported as unavailable. + */ +type DocsFetchResult = + | { outcome: 'ok'; content: string } + /** The site will not serve this path however many times we ask. */ + | { outcome: 'missing' } + /** Transient: 5xx, 429, network error, or timeout. */ + | { outcome: 'unavailable'; retryAfterMs: number | null } + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw toError(signal.reason ?? 'Docs request aborted') + } +} + +async function sleepForRetry(delayMs: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(delayMs) + return + } + + throwIfAborted(signal) + let abortListener: (() => void) | undefined + const aborted = new Promise((_resolve, reject) => { + abortListener = () => reject(toError(signal.reason ?? 'Docs request aborted')) + signal.addEventListener('abort', abortListener, { once: true }) + if (signal.aborted) abortListener() + }) + + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + if (abortListener) signal.removeEventListener('abort', abortListener) + } +} + +async function fetchDocsPageOnce(url: string, signal?: AbortSignal): Promise { + throwIfAborted(signal) + const timeoutSignal = AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS) + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal + + try { + const response = await fetch(url, { + signal: requestSignal, + headers: { Accept: 'text/markdown, text/plain' }, + }) + if (!response.ok) { + logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) + const permanent = + response.status >= 400 && + response.status < 500 && + response.status !== 408 && + response.status !== 429 + if (permanent) return { outcome: 'missing' } + return { + outcome: 'unavailable', + retryAfterMs: parseRetryAfter(response.headers.get('retry-after')), + } + } + return { outcome: 'ok', content: await response.text() } + } catch (err) { + throwIfAborted(signal) + logger.warn('Docs page fetch failed', { url, error: toError(err).message }) + return { outcome: 'unavailable', retryAfterMs: null } + } +} + +async function fetchDocsPage(path: string, signal?: AbortSignal): Promise { + const key = normalizeDocsPath(path) + if (!docsKeyView.has(key)) return { outcome: 'missing' } + const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` + for (let attempt = 1; ; attempt++) { + throwIfAborted(signal) + const result = await fetchDocsPageOnce(url, signal) + throwIfAborted(signal) + if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result + await sleepForRetry(backoffWithJitter(attempt, result.retryAfterMs), signal) + } +} + +/** + * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing + * conditions (directory path, unknown page, site unreachable) so the handler can + * surface the message verbatim. + */ +export async function readDocsPage(path: string, signal?: AbortSignal): Promise { + const key = normalizeDocsPath(path) + if (!docsKeyView.has(key)) { + if (isDocsDir(key)) { + const dir = key.replace(/\/+$/, '') + throw new DocsCorpusError(`${dir} is a directory — glob "${dir}/**" to list its pages.`) + } + throw new DocsCorpusError( + `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` + ) + } + const result = await fetchDocsPage(key, signal) + if (result.outcome === 'missing') { + throw new DocsCorpusError( + `${key} is in the docs index but ${DOCS_BASE_URL} does not serve it — the page was likely moved or removed. Use glob("docs/**") to find the current path; retrying will not help.` + ) + } + if (result.outcome === 'unavailable') { + throw new DocsCorpusError( + `Could not load ${key} from ${DOCS_BASE_URL} — the docs site could not be reached. Retry shortly.` + ) + } + return { content: result.content, totalLines: result.content.split('\n').length } +} + +/** + * Grep one docs page. Directory-wide grep is deliberately unsupported because + * each page is a separate network fetch; use `search_docs` for corpus search or + * `glob("docs/**")` to find a page first. + */ +export async function grepDocs( + path: string, + pattern: string, + options?: GrepOptions, + signal?: AbortSignal +): Promise { + const key = normalizeDocsPath(path) + if (!docsKeyView.has(key)) { + if (isDocsDir(key)) { + throw new DocsCorpusError( + `"${path}" is a docs directory; grep must target one docs page. Use search_docs to search the corpus or glob("${key}/**") to list its pages.` + ) + } + throw new DocsCorpusError( + `"${path}" is not a docs page. Use glob("docs/**") to list the docs corpus.` + ) + } + const page = await readDocsPage(key, signal) + return grepReadResult(key, page, pattern, key, options) +} diff --git a/apps/sim/lib/copilot/docs/docs-path.test.ts b/apps/sim/lib/copilot/docs/docs-path.test.ts new file mode 100644 index 00000000000..c40ba0a7abb --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +describe('foldDocsIndexPath', () => { + it('folds a section overview onto the section path', () => { + expect(foldDocsIndexPath('workflows/index.mdx')).toBe('workflows.mdx') + expect(foldDocsIndexPath('platform/enterprise/index.mdx')).toBe('platform/enterprise.mdx') + }) + + it('leaves a plain page untouched', () => { + expect(foldDocsIndexPath('workflows/blocks/agent.mdx')).toBe('workflows/blocks/agent.mdx') + expect(foldDocsIndexPath('agents.mdx')).toBe('agents.mdx') + }) + + it('does not fold a page merely named index', () => { + expect(foldDocsIndexPath('index.mdx')).toBe('index.mdx') + }) +}) + +describe('docsSourceCandidates', () => { + it('is the inverse of the fold — one candidate always reproduces the input', () => { + for (const publicPath of DOCS_MANIFEST) { + const candidates = docsSourceCandidates(publicPath) + expect(candidates.map(foldDocsIndexPath)).toContain(publicPath) + } + }) + + it('offers both on-disk layouts for a section path', () => { + expect(docsSourceCandidates('workflows.mdx')).toEqual(['workflows.mdx', 'workflows/index.mdx']) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts new file mode 100644 index 00000000000..61785418035 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -0,0 +1,44 @@ +/** + * The single definition of how a docs source file maps onto its public path. + * + * Fumadocs folds a section's `index.mdx` into the section URL itself, so + * `workflows/index.mdx` on disk is `/workflows` on the site (and + * `/workflows/index.mdx` is a 404). Three places need that rule — the manifest + * generator, the `source_document` -> VFS reverse mapping, and the vector + * search's scope filter — and hand-syncing it has bitten this repo before, so + * it lives here. + * + * Deliberately dependency-free: `scripts/sync-docs-manifest.ts` imports this by + * relative path, and it must not pull in the manifest it generates. + */ + +/** Suffix that marks a section overview page on disk. */ +export const DOCS_INDEX_SUFFIX = '/index.mdx' + +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * + * The manifest generator and vector search share this list so search cannot + * return pages that the VFS cannot read. + */ +export const UNMOUNTED_DOCS_SECTIONS = ['academy', 'api-reference'] as const + +/** + * Fold an `en`-relative mdx file path onto its public path — the value used as + * both the `docs/`-relative VFS path and the docs.sim.ai URL path. + */ +export function foldDocsIndexPath(mdxPath: string): string { + return mdxPath.endsWith(DOCS_INDEX_SUFFIX) + ? `${mdxPath.slice(0, -DOCS_INDEX_SUFFIX.length)}.mdx` + : mdxPath +} + +/** + * The inverse of {@link foldDocsIndexPath}: the on-disk file names a public + * path could have come from. A page is stored either as `.mdx` or, when + * it is a section overview, as `/index.mdx`. + */ +export function docsSourceCandidates(publicPath: string): [string, string] { + const stem = publicPath.replace(/\.mdx$/, '') + return [`${stem}.mdx`, `${stem}${DOCS_INDEX_SUFFIX}`] +} diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts new file mode 100644 index 00000000000..16d19141f43 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -0,0 +1,292 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateSearchEmbedding, capturedWhere, capturedLimit, mockRows } = vi.hoisted(() => ({ + mockGenerateSearchEmbedding: vi.fn(), + capturedWhere: { value: undefined as unknown }, + capturedLimit: { value: undefined as number | undefined }, + mockRows: { value: [] as unknown[] }, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mockGenerateSearchEmbedding, +})) + +/** + * Override the global drizzle mock with operators that record their arguments, + * so a test can assert on the `source_document` filter the scope produced. + */ +vi.mock('drizzle-orm', () => { + const op = + (name: string) => + (...args: unknown[]) => ({ op: name, args }) + return { + and: op('and'), + or: op('or'), + eq: op('eq'), + ne: op('ne'), + like: op('like'), + notLike: op('notLike'), + sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }), + } +}) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: (condition: unknown) => { + capturedWhere.value = condition + return { + orderBy: () => ({ + limit: async (n: number) => { + capturedLimit.value = n + return mockRows.value + }, + }), + } + }, + }), + }), + }, +})) + +import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Render a drizzle condition to comparable SQL-ish text for assertions. */ +function whereText(): string { + return JSON.stringify(capturedWhere.value) +} + +describe('searchDocs path scoping', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('excludes unmounted sections when unscoped', async () => { + await searchDocs('cron') + expect(whereText()).toContain('academy/%') + expect(whereText()).toContain('api-reference/%') + }) + + it('treats a bare docs prefix as unscoped', async () => { + await searchDocs('cron', { path: 'docs/' }) + expect(whereText()).toContain('academy/%') + }) + + it('excludes the root homepage when unscoped — its chunks have no live docs/ path', async () => { + await searchDocs('cron') + expect(whereText()).toContain('"op":"ne"') + expect(whereText()).toContain('index.mdx') + }) + + it('scopes a page to both on-disk layouts', async () => { + await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' }) + const text = whereText() + expect(text).toContain('workflows/blocks/agent.mdx') + expect(text).toContain('workflows/blocks/agent/index.mdx') + }) + + it('maps a section overview page onto its index file', async () => { + await searchDocs('cron', { path: 'docs/workflows.mdx' }) + const text = whereText() + expect(text).toContain('workflows/index.mdx') + }) + + it('scopes a directory to its subtree', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + expect(whereText()).toContain('workflows/%') + }) + + it('includes a section overview stored in either on-disk layout', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + const text = whereText() + expect(text).toContain('workflows/%') + expect(text).toContain('workflows.mdx') + }) + + it('rejects a path outside the docs corpus', async () => { + const error = await searchDocs('cron', { path: 'files/report.pdf' }).catch((cause) => cause) + expect(error).toBeInstanceOf(DocsSearchScopeError) + expect(error).toBeInstanceOf(OrchestrationError) + expect(error).toMatchObject({ code: 'validation' }) + }) + + it('rejects a docs path that is neither a page nor a section', async () => { + await expect(searchDocs('cron', { path: 'docs/not-a-real-section' })).rejects.toThrow( + /not a page or section/ + ) + }) + + it('rejects unmounted sections that exist on the site but not in the VFS', async () => { + await expect(searchDocs('cron', { path: 'docs/academy' })).rejects.toThrow( + /not a page or section/ + ) + }) +}) + +describe('searchDocs results', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('returns the docs/ path to read next, folding index pages', async () => { + mockRows.value = [ + { + chunkText: 'body', + sourceDocument: 'workflows/index.mdx', + sourceLink: 'https://docs.sim.ai/workflows', + headerText: 'Overview', + similarity: 0.8, + }, + ] + const { results } = await searchDocs('cron') + expect(results).toEqual([ + { + path: 'docs/workflows.mdx', + url: 'https://docs.sim.ai/workflows', + title: 'Overview', + content: 'body', + similarity: 0.8, + }, + ]) + }) + + it('drops chunks whose source has no live docs/ path', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'academy/lesson-1.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + expect((await searchDocs('cron')).results).toEqual([]) + }) + + it('returns the zero-candidate outcome without querying when the embedding is empty', async () => { + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [] }) + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + }) + }) + + it('drops chunks below the similarity threshold', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + ] + expect((await searchDocs('cron')).results).toEqual([]) + }) +}) + +describe('searchDocs shortfall reporting', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('counts why candidates were dropped so an empty set is explainable', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 2, + droppedBelowThreshold: 1, + droppedStale: 1, + }) + }) + + it('reports no drops when every candidate survives', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome.droppedBelowThreshold).toBe(0) + expect(outcome.droppedStale).toBe(0) + expect(outcome.results).toHaveLength(1) + }) +}) + +describe('searchDocs topK clamping', () => { + beforeEach(() => { + capturedLimit.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('defaults to 5 when unspecified', async () => { + await searchDocs('cron') + expect(capturedLimit.value).toBe(5) + }) + + it('caps at 25 — the documented max, which the old tool never enforced', async () => { + await searchDocs('cron', { topK: 500 }) + expect(capturedLimit.value).toBe(25) + }) + + it('floors at 1', async () => { + await searchDocs('cron', { topK: 0 }) + expect(capturedLimit.value).toBe(1) + await searchDocs('cron', { topK: -8 }) + expect(capturedLimit.value).toBe(1) + }) + + it('truncates a fractional count', async () => { + await searchDocs('cron', { topK: 7.9 }) + expect(capturedLimit.value).toBe(7) + }) + + it('falls back to the default rather than passing NaN to the query', async () => { + await searchDocs('cron', { topK: Number.NaN }) + expect(capturedLimit.value).toBe(5) + await searchDocs('cron', { topK: 'twelve' as unknown as number }) + expect(capturedLimit.value).toBe(5) + await searchDocs('cron', { topK: Number.POSITIVE_INFINITY }) + expect(capturedLimit.value).toBe(5) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts new file mode 100644 index 00000000000..e506dff48ef --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -0,0 +1,207 @@ +import { db } from '@sim/db' +import { docsEmbeddings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm' +import { escapeLikePattern } from '@/lib/api/list-query' +import { + docsPathForSourceDocument, + isDocsDir, + isDocsPage, + normalizeDocsPath, +} from '@/lib/copilot/docs/docs-corpus' +import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' + +const logger = createLogger('DocsSearch') + +const SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_TOP_K = 5 +const MAX_TOP_K = 25 + +export interface DocsSearchResult { + /** The `docs/` VFS path this chunk came from — pass it to `read` for the full page. */ + path: string + /** Public docs.sim.ai URL for the section, for citation. */ + url: string + title: string + content: string + similarity: number +} + +/** + * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is + * applied before the threshold and liveness filters, so these counts are what + * distinguishes "nothing matched" from "matches were filtered out". + */ +export interface DocsSearchOutcome { + results: DocsSearchResult[] + /** Rows the vector search returned before filtering. */ + candidatesConsidered: number + /** Candidates dropped for scoring below the similarity threshold. */ + droppedBelowThreshold: number + /** Candidates dropped because their page is no longer in the docs manifest. */ + droppedStale: number +} + +/** + * Thrown when the caller scopes a search to a `path` that is not a real page or + * section in the docs corpus. Surfaced verbatim so the model can correct itself + * rather than reading an empty result as "the docs say nothing about this". + */ +export class DocsSearchScopeError extends OrchestrationError { + constructor(message: string) { + super('validation', message) + this.name = 'DocsSearchScopeError' + } +} + +/** + * Translate an optional `docs/` VFS path into a `source_document` filter. + * + * `source_document` stores the en-relative mdx file path, while VFS paths mirror + * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but + * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers + * the whole subtree plus the overview in either layout. + * + * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section: + * they are indexed but not mounted in the VFS, so a hit there would be a chunk + * the agent cannot then read. The root homepage (`index.mdx`) is excluded for + * the same reason — the manifest generator drops it (its URL is `/`, which + * redirects), so its chunks would only ever be counted against topK and then + * discarded as stale. + */ +function scopeCondition(path?: string) { + const normalized = normalizeDocsPath(path ?? '') + if (normalized === '' || normalized === 'docs') { + return and( + ne(docsEmbeddings.sourceDocument, 'index.mdx'), + ...UNMOUNTED_DOCS_SECTIONS.map((section) => + notLike(docsEmbeddings.sourceDocument, `${section}/%`) + ) + ) + } + + if (!normalized.startsWith('docs/')) { + throw new DocsSearchScopeError( + `path must be a docs/ VFS path (got "${path}"). Use glob("docs/**") to find one, or omit path to search everything.` + ) + } + + const tail = normalized.slice('docs/'.length) + + if (isDocsPage(normalized)) { + const [pageFile, indexFile] = docsSourceCandidates(tail) + return or( + eq(docsEmbeddings.sourceDocument, pageFile), + eq(docsEmbeddings.sourceDocument, indexFile) + ) + } + + if (isDocsDir(normalized)) { + return or( + like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`), + eq(docsEmbeddings.sourceDocument, `${tail}.mdx`) + ) + } + + throw new DocsSearchScopeError( + `"${path}" is not a page or section in the docs corpus. Use glob("docs/**") to find a valid path, or omit path to search everything.` + ) +} + +/** + * Clamp a caller-supplied result count into [1, {@link MAX_TOP_K}]. + * + * Guards magnitude AND type: `Math.min`/`Math.max` propagate NaN, so a + * non-numeric value would otherwise reach the query as `.limit(NaN)`. The + * generated tool schema rejects a non-number upstream today, but this function + * is also called directly, so it does not rely on that. + */ +function clampTopK(requested: number | undefined): number { + if (requested === undefined || !Number.isFinite(requested)) return DEFAULT_TOP_K + return Math.min(Math.max(Math.trunc(requested), 1), MAX_TOP_K) +} + +/** + * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by + * `scripts/process-docs.ts` on release). Every result carries the `docs/` path + * it came from so the caller can `read` the full page next. + * + * The index lags the VFS: a page added since the last index rebuild is readable + * but not searchable, and a deleted one can still return chunks. Results whose + * source no longer maps to a live `docs/` path are dropped. + * + * Because those drops happen after the SQL LIMIT, a caller can get fewer hits + * than it asked for — or none at all when every candidate was filtered. The + * returned {@link DocsSearchOutcome} reports that explicitly so an empty result + * is never mistaken for "the documentation does not cover this". + */ +export async function searchDocs( + query: string, + options?: { path?: string; topK?: number } +): Promise { + if (!query || typeof query !== 'string') throw new Error('query is required') + + const topK = clampTopK(options?.topK) + const where = scopeCondition(options?.path) + + logger.info('Executing docs search', { + queryLength: query.length, + topK, + path: options?.path ?? null, + }) + + const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + if (!queryEmbedding || queryEmbedding.length === 0) { + return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 } + } + const queryVector = JSON.stringify(queryEmbedding) + + const rows = await db + .select({ + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${queryVector}::vector)`, + }) + .from(docsEmbeddings) + .where(where) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${queryVector}::vector`) + .limit(topK) + + const results: DocsSearchResult[] = [] + let droppedBelowThreshold = 0 + let droppedStale = 0 + for (const row of rows) { + if (row.similarity < SIMILARITY_THRESHOLD) { + droppedBelowThreshold++ + continue + } + const path = docsPathForSourceDocument(row.sourceDocument) + if (!path) { + droppedStale++ + continue + } + results.push({ + path, + url: row.sourceLink, + title: row.headerText, + content: row.chunkText, + similarity: row.similarity, + }) + } + + logger.info('Docs search complete', { + count: results.length, + droppedBelowThreshold, + droppedStale, + }) + return { + results, + candidatesConsidered: rows.length, + droppedBelowThreshold, + droppedStale, + } +} diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts new file mode 100644 index 00000000000..c068a936569 --- /dev/null +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -0,0 +1,388 @@ +/** + * AUTO-GENERATED FILE. DO NOT EDIT. + * Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts. + * Run: bun run docs-manifest:generate. + * + * Every page in the copilot's read-only `docs/` VFS tree, as a path that is + * simultaneously the `docs/`-relative VFS path and the docs.sim.ai URL path + * (so `docs/workflows/blocks/agent.mdx` reads + * `https://docs.sim.ai/workflows/blocks/agent.mdx`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ + 'agents.mdx', + 'agents/choosing.mdx', + 'agents/custom-tools.mdx', + 'agents/mcp.mdx', + 'agents/skills.mdx', + 'chat.mdx', + 'chat/files.mdx', + 'chat/knowledge.mdx', + 'chat/mailer.mdx', + 'chat/research.mdx', + 'chat/tables.mdx', + 'chat/tasks.mdx', + 'chat/workflows.mdx', + 'files.mdx', + 'files/editor.mdx', + 'files/generating.mdx', + 'files/passing-files.mdx', + 'files/using-in-workflows.mdx', + 'getting-started.mdx', + 'integrations.mdx', + 'integrations/a2a.mdx', + 'integrations/agentmail.mdx', + 'integrations/agentphone.mdx', + 'integrations/agiloft.mdx', + 'integrations/ahrefs.mdx', + 'integrations/airtable-service-account.mdx', + 'integrations/airtable.mdx', + 'integrations/airweave.mdx', + 'integrations/algolia.mdx', + 'integrations/amplitude.mdx', + 'integrations/apify.mdx', + 'integrations/apollo.mdx', + 'integrations/appconfig.mdx', + 'integrations/arxiv.mdx', + 'integrations/asana-service-account.mdx', + 'integrations/asana.mdx', + 'integrations/ashby.mdx', + 'integrations/athena.mdx', + 'integrations/atlassian-service-account.mdx', + 'integrations/attio-service-account.mdx', + 'integrations/attio.mdx', + 'integrations/azure_devops.mdx', + 'integrations/box-service-account.mdx', + 'integrations/box.mdx', + 'integrations/brandfetch.mdx', + 'integrations/brex.mdx', + 'integrations/brightdata.mdx', + 'integrations/browser_use.mdx', + 'integrations/buffer.mdx', + 'integrations/calcom-service-account.mdx', + 'integrations/calcom.mdx', + 'integrations/calendly.mdx', + 'integrations/circleback.mdx', + 'integrations/clay.mdx', + 'integrations/clerk.mdx', + 'integrations/clickhouse.mdx', + 'integrations/clickup-service-account.mdx', + 'integrations/clickup.mdx', + 'integrations/cloudflare.mdx', + 'integrations/cloudformation.mdx', + 'integrations/cloudwatch.mdx', + 'integrations/codepipeline.mdx', + 'integrations/confluence.mdx', + 'integrations/context_dev.mdx', + 'integrations/convex.mdx', + 'integrations/crowdstrike.mdx', + 'integrations/cursor.mdx', + 'integrations/dagster.mdx', + 'integrations/databricks.mdx', + 'integrations/datadog.mdx', + 'integrations/datagma.mdx', + 'integrations/daytona.mdx', + 'integrations/deployments.mdx', + 'integrations/devin.mdx', + 'integrations/discord.mdx', + 'integrations/docusign.mdx', + 'integrations/downdetector.mdx', + 'integrations/dropbox.mdx', + 'integrations/dropcontact.mdx', + 'integrations/dspy.mdx', + 'integrations/dub.mdx', + 'integrations/duckduckgo.mdx', + 'integrations/dynamodb.mdx', + 'integrations/dynatrace.mdx', + 'integrations/elasticsearch.mdx', + 'integrations/elevenlabs.mdx', + 'integrations/emailbison.mdx', + 'integrations/embeddings.mdx', + 'integrations/enrich.mdx', + 'integrations/enrichment.mdx', + 'integrations/enrow.mdx', + 'integrations/evernote.mdx', + 'integrations/exa.mdx', + 'integrations/extend.mdx', + 'integrations/fathom.mdx', + 'integrations/file.mdx', + 'integrations/findymail.mdx', + 'integrations/firecrawl.mdx', + 'integrations/fireflies.mdx', + 'integrations/flint.mdx', + 'integrations/gamma.mdx', + 'integrations/github.mdx', + 'integrations/gitlab.mdx', + 'integrations/gmail.mdx', + 'integrations/gong.mdx', + 'integrations/google-service-account.mdx', + 'integrations/google_ads.mdx', + 'integrations/google_appsheet.mdx', + 'integrations/google_bigquery.mdx', + 'integrations/google_books.mdx', + 'integrations/google_calendar.mdx', + 'integrations/google_contacts.mdx', + 'integrations/google_docs.mdx', + 'integrations/google_drive.mdx', + 'integrations/google_forms.mdx', + 'integrations/google_groups.mdx', + 'integrations/google_maps.mdx', + 'integrations/google_meet.mdx', + 'integrations/google_pagespeed.mdx', + 'integrations/google_search.mdx', + 'integrations/google_sheets.mdx', + 'integrations/google_slides.mdx', + 'integrations/google_tasks.mdx', + 'integrations/google_translate.mdx', + 'integrations/google_vault.mdx', + 'integrations/grafana.mdx', + 'integrations/grain.mdx', + 'integrations/granola.mdx', + 'integrations/greenhouse.mdx', + 'integrations/greptile.mdx', + 'integrations/hex.mdx', + 'integrations/hubspot-service-account.mdx', + 'integrations/hubspot-setup.mdx', + 'integrations/hubspot.mdx', + 'integrations/huggingface.mdx', + 'integrations/hunter.mdx', + 'integrations/iam.mdx', + 'integrations/icypeas.mdx', + 'integrations/identity_center.mdx', + 'integrations/imap.mdx', + 'integrations/incidentio.mdx', + 'integrations/infisical.mdx', + 'integrations/instantly.mdx', + 'integrations/intercom.mdx', + 'integrations/jina.mdx', + 'integrations/jira.mdx', + 'integrations/jira_service_management.mdx', + 'integrations/jupyter.mdx', + 'integrations/kalshi.mdx', + 'integrations/ketch.mdx', + 'integrations/knowledge.mdx', + 'integrations/langsmith.mdx', + 'integrations/latex.mdx', + 'integrations/launchdarkly.mdx', + 'integrations/leadmagic.mdx', + 'integrations/lemlist.mdx', + 'integrations/linear-service-account.mdx', + 'integrations/linear.mdx', + 'integrations/linkedin.mdx', + 'integrations/linkup.mdx', + 'integrations/linq.mdx', + 'integrations/logfire.mdx', + 'integrations/logs.mdx', + 'integrations/loops.mdx', + 'integrations/luma.mdx', + 'integrations/mailchimp.mdx', + 'integrations/mailgun.mdx', + 'integrations/managed_agent.mdx', + 'integrations/mem0.mdx', + 'integrations/memory.mdx', + 'integrations/microsoft_ad.mdx', + 'integrations/microsoft_dataverse.mdx', + 'integrations/microsoft_excel.mdx', + 'integrations/microsoft_planner.mdx', + 'integrations/microsoft_teams.mdx', + 'integrations/millionverifier.mdx', + 'integrations/mintlify.mdx', + 'integrations/mistral_parse.mdx', + 'integrations/monday-service-account.mdx', + 'integrations/monday.mdx', + 'integrations/mongodb.mdx', + 'integrations/mysql.mdx', + 'integrations/neo4j.mdx', + 'integrations/neverbounce.mdx', + 'integrations/new_relic.mdx', + 'integrations/notion-service-account.mdx', + 'integrations/notion.mdx', + 'integrations/obsidian.mdx', + 'integrations/okta.mdx', + 'integrations/onedrive.mdx', + 'integrations/onepassword.mdx', + 'integrations/openai.mdx', + 'integrations/outlook.mdx', + 'integrations/pagerduty.mdx', + 'integrations/parallel_ai.mdx', + 'integrations/peopledatalabs.mdx', + 'integrations/perplexity.mdx', + 'integrations/persona.mdx', + 'integrations/pinecone.mdx', + 'integrations/pipedrive-service-account.mdx', + 'integrations/pipedrive.mdx', + 'integrations/polymarket.mdx', + 'integrations/postgresql.mdx', + 'integrations/posthog.mdx', + 'integrations/profound.mdx', + 'integrations/prospeo.mdx', + 'integrations/pulse.mdx', + 'integrations/qdrant.mdx', + 'integrations/quartr.mdx', + 'integrations/quiver.mdx', + 'integrations/railway.mdx', + 'integrations/rb2b.mdx', + 'integrations/rds.mdx', + 'integrations/reddit.mdx', + 'integrations/redis.mdx', + 'integrations/reducto.mdx', + 'integrations/resend.mdx', + 'integrations/revenuecat.mdx', + 'integrations/rippling.mdx', + 'integrations/rocketlane.mdx', + 'integrations/rootly.mdx', + 'integrations/s3.mdx', + 'integrations/salesforce-service-account.mdx', + 'integrations/salesforce.mdx', + 'integrations/sap_concur.mdx', + 'integrations/sap_s4hana.mdx', + 'integrations/secrets_manager.mdx', + 'integrations/sendblue.mdx', + 'integrations/sendgrid.mdx', + 'integrations/sentry.mdx', + 'integrations/serper.mdx', + 'integrations/servicenow.mdx', + 'integrations/ses.mdx', + 'integrations/sftp.mdx', + 'integrations/sharepoint.mdx', + 'integrations/shopify-service-account.mdx', + 'integrations/shopify.mdx', + 'integrations/similarweb.mdx', + 'integrations/sixtyfour.mdx', + 'integrations/slack.mdx', + 'integrations/smartlead.mdx', + 'integrations/smtp.mdx', + 'integrations/snowflake-service-account.mdx', + 'integrations/snowflake.mdx', + 'integrations/sportmonks.mdx', + 'integrations/sqs.mdx', + 'integrations/square.mdx', + 'integrations/ssh.mdx', + 'integrations/stagehand.mdx', + 'integrations/stripe.mdx', + 'integrations/sts.mdx', + 'integrations/supabase.mdx', + 'integrations/table.mdx', + 'integrations/tailscale.mdx', + 'integrations/tavily.mdx', + 'integrations/telegram.mdx', + 'integrations/temporal.mdx', + 'integrations/textract.mdx', + 'integrations/thrive.mdx', + 'integrations/tiktok.mdx', + 'integrations/tinybird.mdx', + 'integrations/trello-service-account.mdx', + 'integrations/trello.mdx', + 'integrations/trigger_dev.mdx', + 'integrations/twilio.mdx', + 'integrations/twilio_sms.mdx', + 'integrations/twilio_voice.mdx', + 'integrations/typeform.mdx', + 'integrations/upstash.mdx', + 'integrations/uptimerobot.mdx', + 'integrations/vanta.mdx', + 'integrations/vercel.mdx', + 'integrations/wealthbox-service-account.mdx', + 'integrations/wealthbox.mdx', + 'integrations/webflow-service-account.mdx', + 'integrations/webflow.mdx', + 'integrations/whatsapp.mdx', + 'integrations/wikipedia.mdx', + 'integrations/wiza.mdx', + 'integrations/wordpress.mdx', + 'integrations/workday.mdx', + 'integrations/x.mdx', + 'integrations/youtube.mdx', + 'integrations/zendesk.mdx', + 'integrations/zep.mdx', + 'integrations/zerobounce.mdx', + 'integrations/zoho-desk-service-account.mdx', + 'integrations/zoho_desk.mdx', + 'integrations/zoom-service-account.mdx', + 'integrations/zoom.mdx', + 'integrations/zoominfo.mdx', + 'introduction.mdx', + 'keyboard-shortcuts.mdx', + 'knowledgebase.mdx', + 'knowledgebase/chunking-strategies.mdx', + 'knowledgebase/connectors.mdx', + 'knowledgebase/debugging-retrieval.mdx', + 'knowledgebase/tags.mdx', + 'knowledgebase/using-in-workflows.mdx', + 'logs-debugging.mdx', + 'logs-debugging/alerts.mdx', + 'logs-debugging/logging.mdx', + 'platform/costs.mdx', + 'platform/credentials.mdx', + 'platform/enterprise.mdx', + 'platform/enterprise/access-control.mdx', + 'platform/enterprise/audit-logs.mdx', + 'platform/enterprise/custom-blocks.mdx', + 'platform/enterprise/data-drains.mdx', + 'platform/enterprise/data-retention.mdx', + 'platform/enterprise/forks.mdx', + 'platform/enterprise/self-hosted.mdx', + 'platform/enterprise/session-policies.mdx', + 'platform/enterprise/sso.mdx', + 'platform/enterprise/verified-domains.mdx', + 'platform/enterprise/whitelabeling.mdx', + 'platform/organization.mdx', + 'platform/permissions.mdx', + 'platform/self-hosting.mdx', + 'platform/self-hosting/architecture.mdx', + 'platform/self-hosting/authentication.mdx', + 'platform/self-hosting/background-jobs.mdx', + 'platform/self-hosting/docker.mdx', + 'platform/self-hosting/email.mdx', + 'platform/self-hosting/environment-variables.mdx', + 'platform/self-hosting/integrations-oauth.mdx', + 'platform/self-hosting/kubernetes.mdx', + 'platform/self-hosting/networking.mdx', + 'platform/self-hosting/object-storage.mdx', + 'platform/self-hosting/observability.mdx', + 'platform/self-hosting/platforms.mdx', + 'platform/self-hosting/redis.mdx', + 'platform/self-hosting/scaling.mdx', + 'platform/self-hosting/security.mdx', + 'platform/self-hosting/troubleshooting.mdx', + 'platform/self-hosting/upgrades.mdx', + 'platform/self-hosting/verify.mdx', + 'platform/workspaces.mdx', + 'quick-reference.mdx', + 'tables.mdx', + 'tables/using-in-workflows.mdx', + 'tables/workflow-columns.mdx', + 'workflows.mdx', + 'workflows/blocks/agent.mdx', + 'workflows/blocks/api.mdx', + 'workflows/blocks/condition.mdx', + 'workflows/blocks/credential.mdx', + 'workflows/blocks/evaluator.mdx', + 'workflows/blocks/function.mdx', + 'workflows/blocks/guardrails.mdx', + 'workflows/blocks/human-in-the-loop.mdx', + 'workflows/blocks/logs.mdx', + 'workflows/blocks/loop.mdx', + 'workflows/blocks/parallel.mdx', + 'workflows/blocks/pi.mdx', + 'workflows/blocks/response.mdx', + 'workflows/blocks/router.mdx', + 'workflows/blocks/variables.mdx', + 'workflows/blocks/wait.mdx', + 'workflows/blocks/webhook.mdx', + 'workflows/blocks/workflow.mdx', + 'workflows/connections.mdx', + 'workflows/data-flow.mdx', + 'workflows/deployment.mdx', + 'workflows/deployment/agent-events.mdx', + 'workflows/deployment/api.mdx', + 'workflows/deployment/chat.mdx', + 'workflows/deployment/mcp.mdx', + 'workflows/how-it-runs.mdx', + 'workflows/triggers/rss.mdx', + 'workflows/triggers/schedule.mdx', + 'workflows/triggers/sim.mdx', + 'workflows/triggers/start.mdx', + 'workflows/triggers/table.mdx', + 'workflows/triggers/webhook.mdx', + 'workflows/variables.mdx', +] diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3d522d70351..d753c42437d 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -56,12 +56,13 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' - | 'get_platform_actions' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -86,6 +87,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -102,7 +104,7 @@ export interface ToolCatalogEntry { | 'run_workflow_until_block' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -177,12 +179,13 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' + | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_log' + | 'get_enterprise_context' | 'get_page_contents' - | 'get_platform_actions' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -207,6 +210,7 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'platform' | 'promote_to_live' | 'query_logs' | 'query_user_table' @@ -223,7 +227,7 @@ export interface ToolCatalogEntry { | 'run_workflow_until_block' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -259,6 +263,7 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' + | 'platform' | 'run' | 'search' | 'table' @@ -2922,6 +2927,14 @@ export const GenerateVideo: ToolCatalogEntry = { capabilities: ['file_input', 'file_output', 'generated_media'], } +export const GetAccountBilling: ToolCatalogEntry = { + id: 'get_account_billing', + name: 'get_account_billing', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetBlockOutputs: ToolCatalogEntry = { id: 'get_block_outputs', name: 'get_block_outputs', @@ -2999,6 +3012,14 @@ export const GetDeploymentLog: ToolCatalogEntry = { }, } +export const GetEnterpriseContext: ToolCatalogEntry = { + id: 'get_enterprise_context', + name: 'get_enterprise_context', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + export const GetPageContents: ToolCatalogEntry = { id: 'get_page_contents', name: 'get_page_contents', @@ -3026,14 +3047,6 @@ export const GetPageContents: ToolCatalogEntry = { }, } -export const GetPlatformActions: ToolCatalogEntry = { - id: 'get_platform_actions', - name: 'get_platform_actions', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -3127,12 +3140,12 @@ export const Grep: ToolCatalogEntry = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.", }, toolTitle: { type: 'string', @@ -3877,6 +3890,26 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const Platform: ToolCatalogEntry = { + id: 'platform', + name: 'platform', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'platform', + internal: true, +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -4559,21 +4592,21 @@ export const Search: ToolCatalogEntry = { internal: true, } -export const SearchDocumentation: ToolCatalogEntry = { - id: 'search_documentation', - name: 'search_documentation', +export const SearchDocs: ToolCatalogEntry = { + id: 'search_docs', + name: 'search_docs', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - query: { type: 'string', description: 'The search query' }, - topK: { - type: 'number', + path: { + type: 'string', description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', }, + query: { type: 'string', description: 'The search query' }, + topK: { type: 'number', description: 'Number of results (default 5, max 25)', default: 5 }, }, required: ['query'], }, @@ -6555,12 +6588,13 @@ export const TOOL_CATALOG: Record = { [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, [GenerateVideo.id]: GenerateVideo, + [GetAccountBilling.id]: GetAccountBilling, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, + [GetEnterpriseContext.id]: GetEnterpriseContext, [GetPageContents.id]: GetPageContents, - [GetPlatformActions.id]: GetPlatformActions, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -6585,6 +6619,7 @@ export const TOOL_CATALOG: Record = { [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [Platform.id]: Platform, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, [QueryUserTable.id]: QueryUserTable, @@ -6601,7 +6636,7 @@ export const TOOL_CATALOG: Record = { [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScrapePage.id]: ScrapePage, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, + [SearchDocs.id]: SearchDocs, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a3fd31e1c85..f4a8ce82175 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2821,6 +2821,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_account_billing: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_block_outputs: { parameters: { type: 'object', @@ -2890,6 +2897,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_enterprise_context: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, get_page_contents: { parameters: { type: 'object', @@ -2918,13 +2932,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -3007,12 +3014,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact supported single-file path searches that file's content; folders and multi-file trees are rejected for content search.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default, or an exact supported file leaf's content when path selects one.", }, toolTitle: { type: 'string', @@ -3735,6 +3742,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + platform: { + parameters: { + properties: { + task: { + description: + "A fully self-contained question about Sim — the platform agent sees none of this conversation, so include every name, id, constraint, and prior finding it needs. Example: 'what is the minimum schedule-trigger interval, and does it differ by plan?' or 'does the agent block persist memory across runs?'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, promote_to_live: { parameters: { type: 'object', @@ -4403,19 +4424,23 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + search_docs: { parameters: { type: 'object', properties: { + path: { + type: 'string', + description: + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', + }, query: { type: 'string', description: 'The search query', }, topK: { type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + description: 'Number of results (default 5, max 25)', + default: 5, }, }, required: ['query'], diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index ee8a2dc4f62..6559a690ca7 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -10,7 +10,6 @@ export interface VfsSnapshotV1 { envVars?: string[] files?: VfsSnapshotV1File[] integrations?: VfsSnapshotV1Integration[] - jobs?: VfsSnapshotV1Job[] knowledgeBases?: VfsSnapshotV1KnowledgeBase[] mcpServers?: VfsSnapshotV1McpServer[] members?: VfsSnapshotV1Member[] @@ -59,19 +58,6 @@ export interface VfsSnapshotV1Integration { providerId: string role?: string } -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Job". - */ -export interface VfsSnapshotV1Job { - cronExpression?: string - id: string - lifecycle?: string - prompt?: string - sourceTaskName?: string - status?: string - title?: string -} /** * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema * via the `definition` "VfsSnapshotV1KnowledgeBase". diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts index d31751c4ad2..42f2fbd9eb0 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts @@ -98,6 +98,24 @@ describe('runHeadlessCopilotLifecycle', () => { expect(result.success).toBe(false) }) + it('forces the server-owned headless classification', async () => { + runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) + + await runHeadlessCopilotLifecycle( + { message: 'hello', messageId: 'req-classification' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + interactive: true, + } + ) + + expect(runCopilotLifecycle).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ interactive: false }) + ) + }) + it('prefers an explicit simRequestId over the payload messageId', async () => { runCopilotLifecycle.mockResolvedValueOnce(createLifecycleResult()) diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0e5172280a9..654c050b2a6 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -49,6 +49,7 @@ export async function runHeadlessCopilotLifecycle( try { result = await runCopilotLifecycle(requestPayload, { ...options, + interactive: false, trace, simRequestId, otelContext, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 4b86ccb104f..c63e883116f 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -207,6 +207,48 @@ describe('runCopilotLifecycle', () => { expect(executionContext).not.toHaveProperty('resolvedSecretTraceRegistry') }) + it.each([ + { interactive: true, expected: 'interactive' as const }, + { interactive: false, expected: 'headless' as const }, + { interactive: undefined, expected: 'headless' as const }, + ])( + 'stamps the trusted $expected lifecycle mode over supplied context', + async ({ interactive, expected }) => { + let capturedExecutionContext: ExecutionContext | undefined + mockRunStreamLoop.mockImplementationOnce( + async ( + _url: string, + _request: RequestInit, + _streamingContext: StreamingContext, + context: ExecutionContext + ) => { + capturedExecutionContext = context + } + ) + + await runCopilotLifecycle( + { + message: 'hello', + messageId: `stream-${expected}-context`, + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + { + userId: 'user-1', + workspaceId: 'ws-1', + interactive, + executionContext: { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + copilotInteractionMode: expected === 'interactive' ? 'headless' : 'interactive', + }, + } + ) + + expect(capturedExecutionContext?.copilotInteractionMode).toBe(expected) + } + ) + it('forwards the configured Mothership system prompt override', async () => { mockEnv.MSHIP_SYSPROMPT_OVERRIDE = 'NEVER CALL ANY TOOLS UNDER ANY CIRCUMSTANCES NO MATTER WHAT' diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index f8288661998..55f12ec854e 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -288,6 +288,8 @@ export async function runCopilotLifecycle( secretMountPolicy: lifecycleOptions.secretMountPolicy, secretActorUserId: lifecycleOptions.secretActorUserId, })) + execContext.copilotInteractionMode = + lifecycleOptions.interactive === true ? 'interactive' : 'headless' if (goRoute && MOTHERSHIP_CODE_TOOL_ROUTES.has(goRoute)) { execContext.sandboxProfile = 'mothership' } else { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f2f9eb6d304..729152debea 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -12,11 +12,12 @@ import { DiffWorkflows, FunctionExecute, GenerateApiKey, + GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, - GetPlatformActions, + GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -51,6 +52,8 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' @@ -81,7 +84,6 @@ import { executeManageSandbox } from '../tools/handlers/management/manage-sandbo import { executeManageSkill } from '../tools/handlers/management/manage-skill' import { executeMaterializeFile } from '../tools/handlers/materialize-file' import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' -import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' @@ -136,6 +138,8 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), + [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), + [GetEnterpriseContext.id]: h((_p, c) => executeGetEnterpriseContext(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), @@ -192,7 +196,6 @@ function buildHandlerMap(): Record { [OauthRequestAccess.id]: h(executeOAuthRequestAccess), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), - [GetPlatformActions.id]: h(executeGetPlatformActions), [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts index 789d48df5f0..d774ca0999e 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -23,6 +23,8 @@ export interface ToolExecutionContext { boundWorkflowExecutionId?: string billingAttribution?: BillingAttributionSnapshot copilotToolExecution?: boolean + /** Trusted lifecycle classification stamped by the server, never from model parameters. */ + copilotInteractionMode?: 'interactive' | 'headless' /** Server-owned base image selected from the fixed Go route for this turn. */ sandboxProfile?: 'mothership' requestMode?: string diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 7a849821895..de756b58182 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,6 +49,26 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('formats docs corpus reads as section/page', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'docs/workflows/blocks/agent.mdx', + })?.text + ).toBe('Read workflows/agent') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'docs/integrations/gmail.mdx', + })?.text + ).toBe('Reading integrations/gmail') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'docs/getting-started.mdx', + })?.text + ).toBe('Attempted to read getting-started') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..5240d0629e6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -97,6 +97,10 @@ function describeReadTarget(path: string | undefined): string | undefined { if (segments.length === 0) return undefined + if (segments[0] === 'docs') { + return describeDocsReadTarget(segments) + } + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] if (!resourceType) { return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') @@ -140,6 +144,19 @@ function describeFileReadTarget(segments: string[]): string { return lastSegment } +/** + * Labels a docs/ corpus read as `
/` (e.g. `workflows/agent` for + * docs/workflows/blocks/agent.mdx). Top-level pages show just their name (e.g. + * `getting-started` for docs/getting-started.mdx). + */ +function describeDocsReadTarget(segments: string[]): string { + const rest = segments.slice(1) + if (rest.length === 0) return 'docs' + const leaf = stripExtension(rest[rest.length - 1]) + if (rest.length === 1) return leaf + return `${rest[0]}/${leaf}` +} + function getLeafResourceSegment(segments: string[]): string { const lastSegment = segments[segments.length - 1] || '' if (hasFileExtension(lastSegment) && segments.length > 1) { diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts new file mode 100644 index 00000000000..c8ac0435a45 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' + +const context = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext + +const snapshot = { + plan: 'team', + billingScope: 'organization' as const, + organizationId: 'org-1', + usage: { + currentPeriodCost: 18.5, + limit: 40, + remaining: 21.5, + percentUsed: 46.25, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), + }, + credits: { balance: 25, scope: 'organization' as const }, +} + +describe('executeGetAccountBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + }) + + it('returns the existing account billing tool result shape after authorization', async () => { + await expect(executeGetAccountBilling(context)).resolves.toEqual({ + success: true, + output: snapshot, + }) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') + }) + + it.each(['headless' as const, undefined])( + 'fails closed for a non-interactive lifecycle (%s) before protected lookup', + async (copilotInteractionMode) => { + const result = await executeGetAccountBilling({ + ...context, + copilotInteractionMode, + }) + + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + } + ) + + it('does not expose an underlying billing failure', async () => { + mocks.getAccountBillingSnapshot.mockRejectedValue( + new Error('connection secret from billing database') + ) + + await expect(executeGetAccountBilling(context)).resolves.toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts new file mode 100644 index 00000000000..051b46f931f --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/account.ts @@ -0,0 +1,27 @@ +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' + +/** + * Live billing snapshot for the requesting user: plan, current-period usage + * against its limit, and purchased credit balance. All three sources are + * org-aware — a member whose subscription lives on an organization gets the + * org's plan, limit, and credit pool, with `billingScope`/`organizationId` + * saying which applied. + */ +export async function executeGetAccountBilling(context: ExecutionContext): Promise { + try { + const output = await executeCopilotPlatformContextUseCase(context, readAccountBilling, { + workspaceId: context.workspaceId ?? '', + }) + return { + success: true, + output, + } + } catch (error) { + return { success: false, error: messageForCopilotPlatformContextError(error) } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts new file mode 100644 index 00000000000..231692c6a76 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts @@ -0,0 +1,367 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetWorkspaceHostContextForViewer, + mockResolveVerifiedUserAccessControlContext, + mockLoadWorkspace, + mockResolvePermission, +} = vi.hoisted(() => ({ + mockGetWorkspaceHostContextForViewer: vi.fn(), + mockResolveVerifiedUserAccessControlContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mockResolveVerifiedUserAccessControlContext, +})) + +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' + +const context = { + userId: 'user-1', + workflowId: '', + workspaceId: 'workspace-1', + chatId: 'chat-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + copilotInteractionMode: 'interactive', +} as const satisfies ExecutionContext + +function enterpriseHost(permission: 'read' | 'write' | 'admin') { + return { + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + billedAccountUserId: 'owner-1', + }, + hostOrganizationId: 'org-1', + ownerBilling: { + plan: 'enterprise', + status: 'active', + isPaid: true, + isPro: true, + isTeam: true, + isEnterprise: true, + isOrgScoped: true, + organizationId: 'org-1', + billingInterval: 'year', + billingBlocked: false, + billingBlockedReason: null, + }, + viewer: { + permission, + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + } +} + +describe('executeGetEnterpriseContext', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLoadWorkspace.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mockResolvePermission.mockResolvedValue('read') + }) + + it('requires a current workspace', async () => { + const result = await executeGetEnterpriseContext({ userId: 'user-1' } as ExecutionContext) + + expect(result).toEqual({ + success: false, + error: 'A current workspace is required to resolve enterprise access.', + }) + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + }) + + it('rejects headless execution before loading workspace or enterprise context', async () => { + const result = await executeGetEnterpriseContext({ + ...context, + copilotInteractionMode: 'headless', + }) + + expect(result).toEqual({ + success: false, + error: 'Live platform context is available only in an interactive Copilot session.', + }) + expect(mockLoadWorkspace).not.toHaveBeenCalled() + expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('keeps external workspace administration separate from organization authority', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + allowedIntegrations: ['slack'], + deniedTools: ['slack_delete_message'], + disableMcpTools: true, + disableInvitations: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1' + ) + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + id: 'workspace-1', + permission: 'admin', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: true, + canManageWorkspace: true, + }, + }, + organization: { + id: 'org-1', + relationship: 'external', + role: null, + canManageOrganization: false, + canManageBilling: false, + plan: 'enterprise', + isEnterprise: true, + }, + accessControl: { + entitled: true, + governingPermissionGroup: { + id: 'group-1', + name: 'Contractors', + resolution: 'all-members', + }, + effectiveConfig: expect.objectContaining({ disableMcpTools: true }), + activeRestrictions: expect.arrayContaining([ + expect.objectContaining({ key: 'allowedIntegrations' }), + expect.objectContaining({ key: 'deniedTools' }), + expect.objectContaining({ key: 'disableMcpTools' }), + expect.objectContaining({ key: 'disableInvitations' }), + ]), + }, + }, + }) + }) + + it('reports an internal member role without granting organization administration', async () => { + const host = enterpriseHost('write') + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...host, + viewer: { + ...host.viewer, + isHostOrganizationMember: true, + organizationRole: 'member', + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'write', + capabilities: { + canRead: true, + canEdit: true, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + organization: { + relationship: 'internal', + role: 'member', + canManageOrganization: false, + canManageBilling: false, + }, + }, + }) + }) + + it('reports read access without write, run, deployment, or administration capabilities', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('read')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + permission: 'read', + capabilities: { + canRead: true, + canEdit: false, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }, + }) + }) + + it('does not advertise deployment when every deployment surface is hidden', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: 'org-1', + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { + capabilities: { + canRun: true, + canDeploy: false, + }, + }, + }, + }) + }) + + it('returns a personal-workspace context without looking up organization membership', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue({ + ...enterpriseHost('write'), + hostOrganizationId: null, + ownerBilling: { + ...enterpriseHost('write').ownerBilling, + plan: 'pro', + isEnterprise: false, + isOrgScoped: false, + organizationId: null, + }, + }) + mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ + organizationId: null, + entitled: false, + permissionGroup: null, + config: null, + }) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toMatchObject({ + success: true, + output: { + workspace: { permission: 'write' }, + organization: null, + accessControl: { + entitled: false, + governingPermissionGroup: null, + effectiveConfig: null, + activeRestrictions: [], + }, + }, + }) + expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null + ) + }) + + it('does not expose enterprise context when workspace access cannot be resolved', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ + success: false, + error: 'Workspace not found or you do not have access.', + }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when workspace context resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockRejectedValue(new Error('workspace lookup failed')) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) + expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('returns a failure when access-control resolution fails', async () => { + mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('write')) + mockResolveVerifiedUserAccessControlContext.mockRejectedValue( + new Error('access-control lookup failed') + ) + + const result = await executeGetEnterpriseContext(context) + + expect(result).toEqual({ + success: false, + error: 'The operation failed due to a system error. Please retry.', + }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts new file mode 100644 index 00000000000..d72f7ae1db0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts @@ -0,0 +1,34 @@ +import { + executeCopilotPlatformContextUseCase, + messageForCopilotPlatformContextError, +} from '@/lib/copilot/application/execute-platform-context-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' + +/** + * Resolves the authenticated user's effective Enterprise access in the current + * workspace. This is an explanatory snapshot; every later mutation must still + * perform its normal server-side authorization at execution time. + */ +export async function executeGetEnterpriseContext( + context: ExecutionContext +): Promise { + if (!context.workspaceId) { + return { + success: false, + error: 'A current workspace is required to resolve enterprise access.', + } + } + + try { + const output = await executeCopilotPlatformContextUseCase(context, readEnterpriseContext, { + workspaceId: context.workspaceId, + }) + return { + success: true, + output, + } + } catch (error) { + return { success: false, error: messageForCopilotPlatformContextError(error) } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts deleted file mode 100644 index c3c3ac14384..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Static content for the get_platform_actions tool. - * Contains the Sim platform quick reference and keyboard shortcuts. - */ -export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts - -## Keyboard Shortcuts -**Mod** = Cmd (macOS) / Ctrl (Windows/Linux). Shortcuts work when canvas is focused. - -### Workflow Actions -| Shortcut | Action | -|----------|--------| -| Mod+Enter | Run workflow (or cancel if running) | -| Mod+Z | Undo | -| Mod+Shift+Z | Redo | -| Mod+C | Copy selected blocks | -| Mod+X | Cut selected blocks | -| Mod+V | Paste blocks | -| Delete/Backspace | Delete selected blocks or edges | -| Shift+L | Auto-layout canvas | -| Mod+Shift+F | Fit to view | -| Mod+Shift+Enter | Accept Copilot changes | - -### Panel Navigation -| Shortcut | Action | -|----------|--------| -| Mod+F | Open workflow search and replace | -| Mod+Alt+F | Focus Toolbar search | - -### Global Navigation -| Shortcut | Action | -|----------|--------| -| Mod+K | Open search | -| Mod+Shift+A | Add new agent workflow | -| Mod+Shift+P | Create workflow | -| Mod+B | Toggle sidebar | -| Mod+L | Go to logs | - -### Utility -| Shortcut | Action | -|----------|--------| -| Mod+D | Clear terminal console | - -### Mouse Controls -| Action | Control | -|--------|---------| -| Pan/move canvas | Left-drag on empty space (hand mode, the default), middle-drag, scroll, or trackpad | -| Select multiple blocks | Shift+drag to draw a selection box. In cursor mode, left-drag on empty space draws it instead | -| Drag block | Left-drag on block header | -| Add to selection | Mod+Click or Shift+Click on blocks | - -## Quick Reference — Workspaces -| Action | How | -|--------|-----| -| Create workspace | Click workspace dropdown → New Workspace | -| Switch workspaces | Click workspace dropdown → Select workspace | -| Invite teammates | Sidebar → Invite | -| Rename/Duplicate/Export/Delete workspace | Right-click workspace → action | - -## Quick Reference — Workflows -| Action | How | -|--------|-----| -| Create workflow | Click + button in sidebar | -| Reorder/move workflows | Drag workflow up/down or onto a folder | -| Import workflow | Click import button in sidebar → Select file | -| Multi-select workflows | Mod+Click or Shift+Click workflows in sidebar | -| Open in new tab | Right-click workflow → Open in New Tab | -| Rename/Duplicate/Export/Delete | Right-click workflow → action | - -## Quick Reference — Blocks -| Action | How | -|--------|-----| -| Add a block | Drag from Toolbar panel, or right-click canvas → Add Block | -| Multi-select blocks | Mod+Click or Shift+Click additional blocks, or Shift+drag a selection box | -| Copy/Paste blocks | Mod+C / Mod+V | -| Duplicate/Delete blocks | Right-click → action | -| Rename a block | Click block name in header | -| Enable/Disable block | Right-click → Enable/Disable | -| Lock/Unlock block | Hover block → Click lock icon (Admin only) | -| Toggle handle orientation | Right-click → Toggle Handles | -| Open a block in the Editor panel | Right-click → Open Editor | -| Move a block out of a loop/parallel | Right-click → Remove from Subflow | -| Configure a block | Select block → use Editor panel on right | - -## Quick Reference — Connections -| Action | How | -|--------|-----| -| Create connection | Drag from output handle to input handle | -| Delete connection | Click edge to select → Delete key | -| Use output in another block | Drag connection tag into input field | - -## Quick Reference — Running & Testing -| Action | How | -|--------|-----| -| Run workflow | Click Run Workflow button or Mod+Enter | -| Stop workflow | Click Stop button or Mod+Enter while running | -| Test with chat | Use Chat panel on the right side | -| Run from block | Hover block → Click play button, or right-click → Run from block | -| Run until block | Right-click block → Run until block | -| View execution logs | Open terminal panel at bottom, or Mod+L | -| Filter/Search/Copy/Clear logs | Terminal panel controls | - -## Quick Reference — Deployment -| Action | How | -|--------|-----| -| Deploy workflow | Click Deploy button in panel | -| Update deployment | Click Update when changes are detected | -| Revert deployment | Previous versions in Deploy tab → Promote to live | -| Copy API endpoint | Deploy tab → API → Copy API cURL | - -## Quick Reference — Variables -| Action | How | -|--------|-----| -| Add/Edit/Delete workflow variable | Panel → Variables → Add Variable | -| Add environment variable | Settings → Environment Variables → Add | -| Reference workflow variable | Use syntax | -| Reference environment variable | Use {{ENV_VAR}} syntax | -` diff --git a/apps/sim/lib/copilot/tools/handlers/platform.ts b/apps/sim/lib/copilot/tools/handlers/platform.ts deleted file mode 100644 index f5cc43f910b..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' - -export async function executeGetPlatformActions( - _rawParams: Record, - _context: ExecutionContext -): Promise { - return { success: true, output: { content: PLATFORM_ACTIONS_CONTENT } } -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 283f63c0709..b360fd5b2af 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' const { getOrMaterializeVFS } = vi.hoisted(() => ({ @@ -702,3 +702,141 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => { expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object)) }) }) + +describe('vfs handlers docs corpus routing', () => { + const fetchMock = vi.fn() + const DOCS_PAGE = 'docs/workflows/blocks/agent.mdx' + + beforeEach(() => { + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('globs the docs corpus without materializing the workspace VFS', async () => { + const result = await executeVfsGlob({ pattern: 'docs/**' }, GREP_CTX) + + expect(result.success).toBe(true) + expect((result.output as { files: string[] }).files).toContain(DOCS_PAGE) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('reads a docs page via the live-site fetch, not the workspace VFS', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => 'line one\nline two', + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + expect(result.output).toEqual({ content: 'line one\nline two', totalLines: 2 }) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('surfaces DocsCorpusError messages verbatim from read, without fetching', async () => { + const unknown = await executeVfsRead({ path: 'docs/not-a-real-page.mdx' }, GREP_CTX) + expect(unknown.success).toBe(false) + expect(unknown.error).toContain('Docs page not found') + + const dir = await executeVfsRead({ path: 'docs/workflows/blocks' }, GREP_CTX) + expect(dir.success).toBe(false) + expect(dir.error).toContain('is a directory') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('greps one docs page and rejects directory scope without touching the workspace VFS', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => 'alpha\ncron beta\ngamma', + }) + + const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) + expect(single.success).toBe(true) + + const directory = await executeVfsGrep( + { pattern: 'cron', path: 'docs/workflows', maxResults: 10_000 }, + GREP_CTX + ) + expect(directory.success).toBe(false) + expect(directory.error).toContain('grep must target one docs page') + expect(fetchMock).toHaveBeenCalledOnce() + + const invalid = await executeVfsGrep({ pattern: 'cron', path: 'docs/not-a-page.mdx' }, GREP_CTX) + expect(invalid.success).toBe(false) + expect(invalid.error).toContain('not a docs page') + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('truncates an oversized multi-line docs page to fit the inline cap', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + const output = result.output as { content: string; totalLines: number } + expect(output.totalLines).toBe(totalLines) + expect(output.content).toContain('[Page truncated: returned lines 1-') + expect(output.content).toMatch(/offset: \d+ and limit: \d+/) + expect(output.content).toContain('reduce the limit if that window is still too large') + expect(JSON.stringify(output).length).toBeLessThanOrEqual(TOOL_RESULT_MAX_INLINE_CHARS) + }) + + it('fails a docs page whose single line cannot fit inline instead of returning it oversized', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => 'z'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1000), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('Grep this page') + }) + + it('rejects an explicit window that still overflows instead of truncating it', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE, offset: 0, limit: totalLines }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('still too large over the requested window') + }) + + it('forwards caller cancellation to docs read and grep without fetching', async () => { + const controller = new AbortController() + controller.abort(new Error('user stopped docs tool')) + const context = { ...GREP_CTX, abortSignal: controller.signal } + + const read = await executeVfsRead({ path: DOCS_PAGE }, context) + const grep = await executeVfsGrep({ pattern: 'agent', path: DOCS_PAGE }, context) + + expect(read).toEqual({ success: false, error: 'user stopped docs tool' }) + expect(grep).toEqual({ success: false, error: 'user stopped docs tool' }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ba8bd734bca..1d07c8aa751 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -4,6 +4,14 @@ import { resolveCopilotKnowledgePrincipal } from '@/lib/copilot/application/exec import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocs, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' @@ -126,6 +134,42 @@ async function canReturnWorkspaceFileValue( return true } +/** + * Trim an oversized docs page to a whole-line prefix that fits the + * inline budget, preserving the true `totalLines` so the model can page through + * the rest with offset/limit. Returns null when not even one line fits — a + * single line longer than the cap — so the caller can fail instead of returning + * an over-cap payload as success. The notice offers grep as an alternative to + * another read because either operation fetches the page once. + */ +function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { + output: { content: string; totalLines: number } + returnedLines: number +} | null { + const lines = page.content.split('\n') + const notice = (shown: number) => + `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown} and limit: ${shown}; reduce the limit if that window is still too large. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` + + let kept = lines.length + while (kept > 0) { + const content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` + if ( + serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS + ) { + return { output: { content, totalLines: page.totalLines }, returnedLines: kept } + } + kept = Math.floor(kept / 2) + } + return null +} + +/** + * Routes grep by content source. `docs/` uses one network-backed docs + * page; `uploads/` uses one chat-scoped upload; workspace file paths use + * one authorized file; all remaining paths use the materialized in-memory VFS. + * External and dynamic file contents are therefore opt-in and single-target, + * while an unscoped grep searches only static VFS resources and metadata. + */ export async function executeVfsGrep( params: Record, context: ExecutionContext @@ -152,16 +196,11 @@ export async function executeVfsGrep( context: (params.context as number) ?? 0, } - // Routing mirrors read/glob: - // - uploads/ -> grep one chat upload's content (chat-scoped) - // - files/ -> grep one workspace file's content (one file only) - // - everything else -> grep the in-memory VFS map (workflow JSON, metadata) - // Chat uploads are opt-in like recently-deleted/: they are never in the VFS - // map, so an unscoped grep can't touch them — only an explicit uploads/ - // path does, and only one upload at a time. let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined - if (isChatUploadGrepPath(rawPath)) { + if (rawPath !== undefined && isDocsPath(rawPath)) { + result = await grepDocs(rawPath, pattern, grepOptions, context.abortSignal) + } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } } @@ -223,8 +262,8 @@ export async function executeVfsGrep( } catch (err) { // Expected single-file scoping / no-text / too-large conditions: surface the // message verbatim instead of logging an internal failure. - if (err instanceof WorkspaceFileGrepError) { - logger.debug('vfs_grep workspace file rejected', { + if (err instanceof WorkspaceFileGrepError || err instanceof DocsCorpusError) { + logger.debug('vfs_grep single-file scope rejected', { pattern, path: rawPath, error: err.message, @@ -255,6 +294,12 @@ export async function executeVfsGlob( } try { + if (couldMatchDocsScope(pattern)) { + const files = globDocs(pattern) + logger.debug('vfs_glob docs result', { pattern, fileCount: files.length }) + return { success: true, output: { files } } + } + const vfs = await getGatedVFS(context) let files = vfs.glob(pattern) @@ -323,6 +368,34 @@ export async function executeVfsRead( } } + if (isDocsPath(path)) { + const page = await readDocsPage(path, context.abortSignal) + const windowed = applyWindow(page) + if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { + if (offset !== undefined || limit !== undefined) { + return { + success: false, + error: `${path} is still too large over the requested window. Narrow offset/limit, or grep this page for the section you need.`, + } + } + const truncated = truncateDocsPageToInlineCap(page) + if (!truncated) { + return { + success: false, + error: `${path} is too large to return inline even truncated. Grep this page for the section you need.`, + } + } + logger.debug('vfs_read truncated oversized docs page', { + path, + totalLines: page.totalLines, + returnedLines: truncated.returnedLines, + }) + return { success: true, output: truncated.output } + } + logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) + return { success: true, output: windowed } + } + // Handle chat-scoped uploads via the uploads/ virtual prefix. // Uploads are flat and have no metadata/content split like files/ — the upload // IS the first path segment after uploads/. Any trailing segment (e.g. a @@ -482,6 +555,10 @@ export async function executeVfsRead( output: result, } } catch (err) { + if (err instanceof DocsCorpusError) { + logger.debug('vfs_read docs page rejected', { path, error: err.message }) + return { success: false, error: err.message } + } logger.error('vfs_read failed', { path, error: toError(err).message, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index f8f33c8289e..801372da6f1 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -2,8 +2,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ +const { executeWorkflowUseCaseMock, listUserWorkspacesMock } = vi.hoisted(() => ({ executeWorkflowUseCaseMock: vi.fn(), + listUserWorkspacesMock: vi.fn(), })) vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ @@ -12,7 +13,54 @@ vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ getErrorMessage(error, 'Workflow operation failed'), })) -import { executeGetBlockOutputs } from './queries' +vi.mock('@/lib/workspaces/utils', () => ({ + listUserWorkspaces: listUserWorkspacesMock, +})) + +import { + executeGetBlockOutputs, + executeListUserWorkspaces, +} from '@/lib/copilot/tools/handlers/workflow/queries' + +describe('executeListUserWorkspaces', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('marks the current workspace in the accessible workspace list', async () => { + listUserWorkspacesMock.mockResolvedValue([ + { workspaceId: 'workspace-1', workspaceName: 'One', role: 'owner' }, + { workspaceId: 'workspace-2', workspaceName: 'Two', role: 'read' }, + ]) + + const result = await executeListUserWorkspaces({ + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-2', + }) + + expect(listUserWorkspacesMock).toHaveBeenCalledWith('user-1') + expect(result).toEqual({ + success: true, + output: { + workspaces: [ + { + workspaceId: 'workspace-1', + workspaceName: 'One', + role: 'owner', + isCurrent: false, + }, + { + workspaceId: 'workspace-2', + workspaceName: 'Two', + role: 'read', + isCurrent: true, + }, + ], + }, + }) + }) +}) describe('executeGetBlockOutputs', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 8861dbc98d6..0291fdee8fc 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -34,7 +34,10 @@ export async function executeListUserWorkspaces( context: ExecutionContext ): Promise { try { - const workspaces = await listUserWorkspaces(context.userId) + const workspaces = (await listUserWorkspaces(context.userId)).map((workspace) => ({ + ...workspace, + isCurrent: workspace.workspaceId === context.workspaceId, + })) return { success: true, output: { workspaces } } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts new file mode 100644 index 00000000000..3634648b5b8 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { isKnownTool, isSimExecuted } from '@/lib/copilot/tool-executor/router' +import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' +import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' + +/** + * `executeTool` gates on `isKnownTool` (catalog membership) before it ever + * consults the handler registry, so a sim-routed tool needs every link of this + * chain or dispatch rejects it before the handler is reached. These assertions + * pin that chain for search_docs. + */ +describe('search_docs dispatch chain', () => { + it('is in the catalog, so dispatch does not reject it as unknown', () => { + expect(isKnownTool('search_docs')).toBe(true) + }) + + it('routes to sim, so dispatch reaches the server tool registry', () => { + expect(isSimExecuted('search_docs')).toBe(true) + }) + + it('has a registered server handler', () => { + expect(getRegisteredServerToolNames()).toContain('search_docs') + }) +}) + +describe('removed docs-tool ids', () => { + for (const removed of ['search_documentation', 'get_platform_actions']) { + it(`${removed} is absent from the catalog, registries, and hidden-tool set`, () => { + expect(TOOL_CATALOG[removed]).toBeUndefined() + expect(isKnownTool(removed)).toBe(false) + expect(getRegisteredServerToolNames()).not.toContain(removed) + expect(getHiddenToolNames().has(removed)).toBe(false) + }) + } +}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts new file mode 100644 index 00000000000..1b01c4c8dd5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocsSearchOutcome } from '@/lib/copilot/docs/docs-search' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const { mockSearchDocs } = vi.hoisted(() => ({ + mockSearchDocs: vi.fn(), +})) + +vi.mock('@/lib/copilot/docs/docs-search', () => ({ + searchDocs: mockSearchDocs, +})) + +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' + +function outcome(overrides: Partial): DocsSearchOutcome { + return { + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + ...overrides, + } +} + +const RESULT = { + path: 'docs/agents.mdx', + url: 'https://docs.sim.ai/agents', + title: 'Agents', + content: 'body', + similarity: 0.9, +} + +const CONTEXT = { + userId: 'user-1', + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), +} + +describe('searchDocsServerTool', () => { + beforeEach(() => { + mockSearchDocs.mockReset() + }) + + it('forwards query, path, and topK to the search layer', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute( + { + query: 'how do agents work', + path: 'docs/agents.mdx', + topK: 7, + }, + CONTEXT + ) + + expect(mockSearchDocs).toHaveBeenCalledWith('how do agents work', { + path: 'docs/agents.mdx', + topK: 7, + }) + expect(output).toEqual({ + results: [RESULT], + query: 'how do agents work', + totalResults: 1, + }) + }) + + it('omits the note when nothing was dropped', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) + + expect(output.note).toBeUndefined() + }) + + it('explains when the index returns no candidates', async () => { + mockSearchDocs.mockResolvedValue(outcome({})) + + const output = await searchDocsServerTool.execute({ query: 'brand new feature' }, CONTEXT) + + expect(output.note).toContain('search index may lag') + expect(output.note).toContain('read it directly') + expect(output.note).toContain('glob("docs/**")') + }) + + it('explains an empty result set caused by filtering, so it does not read as missing docs', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ candidatesConsidered: 2, droppedBelowThreshold: 1, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) + + expect(output.note).toContain('does NOT mean the docs lack this topic') + expect(output.note).toContain('1 scored too low') + expect(output.note).toContain('1 point at pages no longer in the docs') + }) + + it('notes threshold-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 3, droppedBelowThreshold: 2 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) + + expect(output.note).toContain('Returned 1 of 3 candidate(s)') + expect(output.note).toContain('2 scored too low') + expect(output.note).not.toContain('no longer in the docs') + }) + + it('notes stale-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 2, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }, CONTEXT) + + expect(output.note).toContain('1 point at pages no longer in the docs') + expect(output.note).not.toContain('scored too low') + }) + + it('projects resolved secrets before embedding or returning the query', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { + name: 'DOCS_QUERY', + plaintext: 'private docs query', + encryptedValue: 'ciphertext', + }, + ]) + registry.recordResolved('DOCS_QUERY', 'private docs query') + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute( + { query: 'private docs query' }, + { userId: 'user-1', resolvedSecretTraceRegistry: registry } + ) + + expect(mockSearchDocs).toHaveBeenCalledWith('{{DOCS_QUERY}}', { + path: undefined, + topK: undefined, + }) + expect(output.query).toBe('{{DOCS_QUERY}}') + expect(JSON.stringify(output)).not.toContain('private docs query') + }) + + it('fails closed when secret provenance is unavailable', async () => { + await expect(searchDocsServerTool.execute({ query: 'query' })).rejects.toThrow( + 'Docs search query could not be processed safely' + ) + expect(mockSearchDocs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts new file mode 100644 index 00000000000..7603bc9e6b2 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -0,0 +1,79 @@ +import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search' +import { searchDocs } from '@/lib/copilot/docs/docs-search' +import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { ServerToolModelInputError } from '@/lib/copilot/tools/server/model-input' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' + +interface SearchDocsParams { + query: string + topK?: number + path?: string +} + +interface SearchDocsOutput { + results: DocsSearchResult[] + query: string + totalResults: number + /** + * Present only when the vector search matched chunks that were then filtered + * out. Without it an empty result set reads as "the docs do not cover this", + * which sends the caller off to guess instead of rephrasing or falling back + * to glob. + */ + note?: string +} + +/** + * Explain a short or empty result set in terms the caller can act on. Returns + * undefined when nothing was dropped — the common case needs no commentary. + */ +function shortfallNote(outcome: Awaited>): string | undefined { + const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome + if (results.length === 0 && candidatesConsidered === 0) { + return 'No indexed candidates were returned. The search index may lag the live docs. If you know the page, read it directly; otherwise use glob("docs/**") to find the current path.' + } + if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined + + const reasons: string[] = [] + if (droppedBelowThreshold > 0) + reasons.push(`${droppedBelowThreshold} scored too low to be relevant`) + if (droppedStale > 0) { + reasons.push( + `${droppedStale} point at pages no longer in the docs (the search index lags the site)` + ) + } + const dropped = reasons.join(' and ') + + return results.length === 0 + ? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or browse with glob("docs/**").` + : `Returned ${results.length} of ${candidatesConsidered} candidate(s); ${dropped}. Rephrase or widen the query if these look off-topic.` +} + +/** + * Vector search over Sim's product documentation, scoped to the same pages the + * agent can `read` from the `docs/` VFS tree. Normal delegation exposes it to + * the platform agent; the `@Docs` compatibility path also invokes it directly. + * Corpus logic lives in `@/lib/copilot/docs/docs-search`. + */ +export const searchDocsServerTool: BaseServerTool = { + name: SearchDocs.id, + async execute(params: SearchDocsParams, context?: ServerToolContext): Promise { + const queryProjection = projectResolvedSecretModelContent( + params.query, + context?.resolvedSecretTraceRegistry + ) + if (!queryProjection.safe || typeof queryProjection.value !== 'string') { + throw new ServerToolModelInputError('Docs search query could not be processed safely') + } + const query = queryProjection.value + const outcome = await searchDocs(query, { path: params.path, topK: params.topK }) + const note = shortfallNote(outcome) + return { + results: outcome.results, + query, + totalResults: outcome.results.length, + ...(note ? { note } : {}), + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts deleted file mode 100644 index 14693f75913..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGenerateSearchEmbedding } = vi.hoisted(() => ({ - mockGenerateSearchEmbedding: vi.fn(), -})) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchDocumentation: { id: 'search_documentation' }, -})) -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: mockGenerateSearchEmbedding, -})) - -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -describe('documentation search model boundary', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [], isBYOK: false }) - }) - - it('preserves a query that merely collides with ambient secret plaintext', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'DOCS_QUERY', - plaintext: 'private documentation query', - encryptedValue: 'encrypted-query', - }, - ]) - registry.recordResolved('DOCS_QUERY', 'private documentation query') - - const result = await searchDocumentationServerTool.execute( - { query: 'private documentation query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith('private documentation query') - expect(result).toEqual({ - results: [], - query: 'private documentation query', - totalResults: 0, - }) - - const logger = loggerMock.createLogger.mock.results.at(-1)?.value - expect(logger?.info).toHaveBeenCalledWith('Executing docs search', { - queryLength: 'private documentation query'.length, - topK: 10, - }) - expect(JSON.stringify(logger?.info.mock.calls)).not.toContain('private documentation query') - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts deleted file mode 100644 index ad14c3937a6..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' - -interface DocsSearchParams { - query: string - topK?: number - threshold?: number -} - -const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 - -export const searchDocumentationServerTool: BaseServerTool = { - name: SearchDocumentation.id, - async execute(params: DocsSearchParams): Promise { - const logger = createLogger('SearchDocumentationServerTool') - const { query, topK = 10, threshold } = params - if (!query || typeof query !== 'string') throw new Error('query is required') - - logger.info('Executing docs search', { queryLength: query.length, topK }) - - const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD - - const modelQuery = query - const { embedding: queryEmbedding } = await generateSearchEmbedding(modelQuery) - if (!queryEmbedding || queryEmbedding.length === 0) { - return { results: [], query, totalResults: 0 } - } - - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - const filteredResults = results.filter((r) => r.similarity >= similarityThreshold) - const documentationResults = filteredResults.map((r, idx) => ({ - id: idx + 1, - title: String(r.headerText || 'Untitled Section'), - url: String(r.sourceLink || '#'), - content: String(r.chunkText || ''), - similarity: r.similarity, - })) - - logger.info('Docs search complete', { count: documentationResults.length }) - return { results: documentationResults, query, totalResults: documentationResults.length } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7d87b432218..ab6a882b30e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -24,7 +24,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' import { enrichmentRunServerTool } from '@/lib/copilot/tools/server/enrichment/enrichment-run' import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file' import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' @@ -168,7 +168,7 @@ const baseServerToolRegistry: Record = { [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, - [searchDocumentationServerTool.name]: searchDocumentationServerTool, + [searchDocsServerTool.name]: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 027ce68d915..cff4b8ef479 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -76,6 +76,25 @@ describe('getToolDisplayTitle natural-language coverage', () => { 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') + expect(getToolDisplayTitle('get_enterprise_context')).toBe('Checking enterprise access') + }) + + it('includes the query in search_docs titles', () => { + expect(getToolDisplayTitle('search_docs')).toBe('Searching Sim docs') + expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( + 'Searching Sim docs for "loop blocks iteration"' + ) + expect( + getToolCompletedTitle( + getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) + ) + ).toBe('Searched Sim docs for "how to read workflow logs"') + expect( + getToolDisplayTitle('search_docs', { + query: + 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', + })?.length + ).toBeLessThanOrEqual('Searching Sim docs for ""'.length + 60 + '...'.length) }) it('falls back to running code for function_execute without a title', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index f15c80c5224..9b1efb92534 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix } from '@sim/utils/string' +import { stripVersionSuffix, truncate } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -474,10 +474,12 @@ const TOOL_TITLES: Record = { function_execute: 'Running code', complete_scheduled_task: 'Completing scheduled task', generate_api_key: 'Generating API key', + get_account_billing: 'Checking plan and usage', 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_enterprise_context: 'Checking enterprise access', get_platform_actions: 'Getting platform actions', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', @@ -503,7 +505,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_documentation: 'Searching documentation', + search_docs: 'Searching Sim docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', @@ -536,6 +538,7 @@ const TOOL_TITLES: Record = { research: 'Research Agent', scout: 'Scout Agent', search: 'Search Agent', + platform: 'Platform Agent', file: 'File Agent', media: 'Media Agent', browser: 'Browser Agent', @@ -803,6 +806,10 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } + case 'search_docs': { + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target ? `Searching Sim docs for "${truncate(target, 60)}"` : 'Searching Sim docs' + } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' diff --git a/apps/sim/lib/organizations/settings-access.test.ts b/apps/sim/lib/organizations/settings-access.test.ts index 3ac741ff69d..f598c09fd85 100644 --- a/apps/sim/lib/organizations/settings-access.test.ts +++ b/apps/sim/lib/organizations/settings-access.test.ts @@ -46,6 +46,14 @@ describe('organization settings access', () => { }) }) + it('fails closed when a stored membership has a non-canonical role', async () => { + queueTableRows(member, [{ role: 'billing-owner' }]) + + await expect(getOrganizationSettingsAccess('organization-route', 'viewer')).rejects.toThrow( + 'Invalid role' + ) + }) + it('allows members to view the roster but reserves control-plane sections for admins', async () => { queueTableRows(member, [{ role: 'member' }]) await expect( diff --git a/apps/sim/lib/organizations/settings-access.ts b/apps/sim/lib/organizations/settings-access.ts index 3281a11a930..db374c716db 100644 --- a/apps/sim/lib/organizations/settings-access.ts +++ b/apps/sim/lib/organizations/settings-access.ts @@ -7,11 +7,12 @@ import { type OrganizationSettingsSection, resolveOrganizationSectionAccess, } from '@/components/settings/navigation' +import { type OrganizationRole, organizationRoleSchema } from '@/lib/api/contracts/primitives' interface OrganizationSettingsAccess { isAdmin: boolean isMember: boolean - role: string | null + role: OrganizationRole | null } /** @@ -28,7 +29,7 @@ async function resolveOrganizationSettingsAccess( .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) .limit(1) - const role = membership?.role ?? null + const role = membership ? organizationRoleSchema.parse(membership.role) : null return { role, isMember: role !== null, diff --git a/apps/sim/lib/permission-groups/features.test.ts b/apps/sim/lib/permission-groups/features.test.ts new file mode 100644 index 00000000000..c7496830cd3 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getActivePermissionGroupRestrictions, + PLATFORM_FEATURES, +} from '@/lib/permission-groups/features' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + type PermissionGroupConfig, +} from '@/lib/permission-groups/types' + +describe('getActivePermissionGroupRestrictions', () => { + it('returns no restrictions for an absent or unrestricted config', () => { + expect(getActivePermissionGroupRestrictions(null)).toEqual([]) + expect(getActivePermissionGroupRestrictions(DEFAULT_PERMISSION_GROUP_CONFIG)).toEqual([]) + }) + + it.each([ + { + key: 'allowedIntegrations', + emptyValue: [], + limitedValue: ['slack'], + emptyDescription: 'No non-exempt integrations or blocks are allowed.', + limitedDescription: + 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.', + }, + { + key: 'allowedModelProviders', + emptyValue: [], + limitedValue: ['openai'], + emptyDescription: 'No model providers are allowed.', + limitedDescription: 'Model providers are limited to effectiveConfig.allowedModelProviders.', + }, + { + key: 'allowedFileShareAuthTypes', + emptyValue: [], + limitedValue: ['password'], + emptyDescription: 'No public file-share authentication modes are allowed.', + limitedDescription: + 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.', + }, + { + key: 'allowedChatDeployAuthTypes', + emptyValue: [], + limitedValue: ['sso'], + emptyDescription: 'No chat deployment authentication modes are allowed.', + limitedDescription: + 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.', + }, + ] as const)( + 'describes empty and limited $key allowlists', + ({ key, emptyValue, limitedValue, emptyDescription, limitedDescription }) => { + const emptyConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: emptyValue } + const limitedConfig = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: limitedValue } + + expect(getActivePermissionGroupRestrictions(emptyConfig)).toEqual([ + { key, description: emptyDescription }, + ]) + expect(getActivePermissionGroupRestrictions(limitedConfig)).toEqual([ + { key, description: limitedDescription }, + ]) + } + ) + + it.each([ + { + key: 'deniedModels', + value: ['gpt-4o'], + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }, + { + key: 'deniedTools', + value: ['slack_delete_message'], + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }, + ] as const)('describes a populated $key denylist', ({ key, value, description }) => { + const config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, [key]: value } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([{ key, description }]) + }) + + it.each(PLATFORM_FEATURES)( + 'uses the shared prose for $configKey when enabled', + ({ configKey, hint }) => { + const config: PermissionGroupConfig = { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + [configKey]: true, + } + + expect(getActivePermissionGroupRestrictions(config)).toEqual([ + { key: configKey, description: hint }, + ]) + } + ) +}) diff --git a/apps/sim/lib/permission-groups/features.ts b/apps/sim/lib/permission-groups/features.ts new file mode 100644 index 00000000000..78aa4f79545 --- /dev/null +++ b/apps/sim/lib/permission-groups/features.ts @@ -0,0 +1,228 @@ +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' + +type BooleanPermissionGroupConfigKey = { + [Key in keyof PermissionGroupConfig]: PermissionGroupConfig[Key] extends boolean ? Key : never +}[keyof PermissionGroupConfig] + +export interface PermissionGroupPlatformFeature { + id: string + label: string + category: string + configKey: BooleanPermissionGroupConfigKey + hint: string +} + +export interface ActivePermissionGroupRestriction { + key: keyof PermissionGroupConfig + description: string +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +export const PLATFORM_CATEGORY_ORDER: readonly string[] = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] as const + +/** User-facing descriptions shared by the Access Control editor and live permission context. */ +export const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab', + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab', + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot', + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab', + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab', + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab', + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab', + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi', + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp', + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools', + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools', + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills', + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans', + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations', + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab', + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi', + hint: 'Disable public API access to deployed workflows.', + }, + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot', + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing', + hint: 'Disable public file-share links.', + }, +] as const satisfies readonly PermissionGroupPlatformFeature[] + +/** Returns only restrictions that actively constrain the current user. */ +export function getActivePermissionGroupRestrictions( + config: PermissionGroupConfig | null +): ActivePermissionGroupRestriction[] { + if (!config) return [] + + const restrictions: ActivePermissionGroupRestriction[] = [] + + if (config.allowedIntegrations !== null) { + restrictions.push({ + key: 'allowedIntegrations', + description: + config.allowedIntegrations.length > 0 + ? 'Integrations and blocks are limited to effectiveConfig.allowedIntegrations.' + : 'No non-exempt integrations or blocks are allowed.', + }) + } + if (config.allowedModelProviders !== null) { + restrictions.push({ + key: 'allowedModelProviders', + description: + config.allowedModelProviders.length > 0 + ? 'Model providers are limited to effectiveConfig.allowedModelProviders.' + : 'No model providers are allowed.', + }) + } + if (config.deniedModels.length > 0) { + restrictions.push({ + key: 'deniedModels', + description: 'Models listed in effectiveConfig.deniedModels are blocked.', + }) + } + if (config.deniedTools.length > 0) { + restrictions.push({ + key: 'deniedTools', + description: 'Integration tools listed in effectiveConfig.deniedTools are blocked.', + }) + } + if (config.allowedFileShareAuthTypes !== null) { + restrictions.push({ + key: 'allowedFileShareAuthTypes', + description: + config.allowedFileShareAuthTypes.length > 0 + ? 'Public file-share authentication is limited to effectiveConfig.allowedFileShareAuthTypes.' + : 'No public file-share authentication modes are allowed.', + }) + } + if (config.allowedChatDeployAuthTypes !== null) { + restrictions.push({ + key: 'allowedChatDeployAuthTypes', + description: + config.allowedChatDeployAuthTypes.length > 0 + ? 'Chat deployment authentication is limited to effectiveConfig.allowedChatDeployAuthTypes.' + : 'No chat deployment authentication modes are allowed.', + }) + } + + for (const feature of PLATFORM_FEATURES) { + if (config[feature.configKey]) { + restrictions.push({ key: feature.configKey, description: feature.hint }) + } + } + + return restrictions +} diff --git a/apps/sim/lib/platform-context/application/authorization.ts b/apps/sim/lib/platform-context/application/authorization.ts new file mode 100644 index 00000000000..11fe1a00bf5 --- /dev/null +++ b/apps/sim/lib/platform-context/application/authorization.ts @@ -0,0 +1,12 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export const PLATFORM_CONTEXT_DELEGATION_AUDIENCE = 'sim:platform-context' + +export const platformContextDelegationPolicy = { + audience: PLATFORM_CONTEXT_DELEGATION_AUDIENCE, + isWithinScope: ( + principal: DelegatedPrincipal, + context: ActiveWorkspaceApplicationContext + ): boolean => principal.workspaceId === context.workspaceId, +} as const diff --git a/apps/sim/lib/platform-context/application/context.ts b/apps/sim/lib/platform-context/application/context.ts new file mode 100644 index 00000000000..f25cc85b882 --- /dev/null +++ b/apps/sim/lib/platform-context/application/context.ts @@ -0,0 +1,14 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +/** Loads canonical active workspace state before authorizing a live platform-context read. */ +export async function resolvePlatformContextWorkspace( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} diff --git a/apps/sim/lib/platform-context/application/operations.ts b/apps/sim/lib/platform-context/application/operations.ts new file mode 100644 index 00000000000..36c650a064f --- /dev/null +++ b/apps/sim/lib/platform-context/application/operations.ts @@ -0,0 +1,24 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY = { + principalKinds: ['delegated'], + delegatedServices: ['copilot'], +} as const + +export const platformContextOperations = { + readAccountBilling: defineWorkspaceOperation({ + id: 'platform_context.account_billing.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), + readEnterpriseContext: defineWorkspaceOperation({ + id: 'platform_context.enterprise.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...LIVE_PLATFORM_CONTEXT_PRINCIPAL_POLICY, + }), +} as const + +export type PlatformContextOperation = + (typeof platformContextOperations)[keyof typeof platformContextOperations] diff --git a/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts new file mode 100644 index 00000000000..64319782d72 --- /dev/null +++ b/apps/sim/lib/platform-context/application/platform-context-use-cases.test.ts @@ -0,0 +1,221 @@ +/** + * @vitest-environment node + */ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getAccountBillingSnapshot: vi.fn(), + getWorkspaceHostContextForViewer: vi.fn(), + resolveVerifiedUserAccessControlContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ + getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, +})) + +vi.mock('@/lib/workspaces/host-context', () => ({ + getWorkspaceHostContextForViewer: mocks.getWorkspaceHostContextForViewer, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + resolveVerifiedUserAccessControlContext: mocks.resolveVerifiedUserAccessControlContext, +})) + +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' +import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' +import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', +} + +function copilotPrincipal(): DelegatedPrincipal { + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:platform-context', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + } +} + +describe('platform context application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('authorizes a current Copilot subject before reading account billing', async () => { + const snapshot = { + plan: 'pro', + billingScope: 'user', + organizationId: null, + usage: {}, + credits: {}, + } + mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) + + await expect( + readAccountBilling.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toBe(snapshot) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'org-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') + }) + + it.each([ + { + name: 'workspace API key', + principal: { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + }, + { + name: 'executor delegation', + principal: { ...copilotPrincipal(), serviceId: 'executor' as const }, + }, + ])('rejects a $name before loading protected account context', async ({ principal }) => { + await expect( + readAccountBilling.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() + }) + + it('does not load enterprise context when current workspace access is absent', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.getWorkspaceHostContextForViewer).not.toHaveBeenCalled() + expect(mocks.resolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() + }) + + it('projects enterprise context only after authorization', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'admin', + isHostOrganizationMember: false, + isHostOrganizationAdmin: false, + organizationRole: null, + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: DEFAULT_PERMISSION_GROUP_CONFIG, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + id: 'workspace-1', + capabilities: { canRead: true, canEdit: true, canDeploy: true }, + }, + organization: { + id: 'org-1', + relationship: 'external', + canManageOrganization: false, + }, + accessControl: { entitled: true }, + }) + expect(mocks.getWorkspaceHostContextForViewer).toHaveBeenCalledWith('workspace-1', 'user-1') + }) + + it('allows read-role execution but hides deployment when every deploy surface is hidden', async () => { + mocks.getWorkspaceHostContextForViewer.mockResolvedValue({ + workspace: { + id: 'workspace-1', + name: 'Customer Support', + workspaceMode: 'collaborative', + }, + hostOrganizationId: 'org-1', + ownerBilling: { plan: 'enterprise', isEnterprise: true }, + viewer: { + permission: 'read', + isHostOrganizationMember: true, + isHostOrganizationAdmin: false, + organizationRole: 'member', + }, + }) + mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ + entitled: true, + permissionGroup: null, + config: { + ...DEFAULT_PERMISSION_GROUP_CONFIG, + hideDeployApi: true, + hideDeployMcp: true, + hideDeployChatbot: true, + }, + }) + + await expect( + readEnterpriseContext.execute({ + principal: copilotPrincipal(), + input: { workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workspace: { + capabilities: { + canRead: true, + canEdit: false, + canRun: true, + canDeploy: false, + canManageWorkspace: false, + }, + }, + }) + }) +}) diff --git a/apps/sim/lib/platform-context/application/read-account-billing.ts b/apps/sim/lib/platform-context/application/read-account-billing.ts new file mode 100644 index 00000000000..ee3bd7ed698 --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-account-billing.ts @@ -0,0 +1,22 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AccountBillingSnapshot, + getAccountBillingSnapshot, +} from '@/lib/billing/core/account-billing-snapshot' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' + +export interface ReadAccountBillingInput { + workspaceId: string +} + +export const readAccountBilling = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readAccountBilling, + resolveContext: ({ input }: { input: ReadAccountBillingInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + execute: async ({ principal }): Promise => + getAccountBillingSnapshot(requirePrincipalSubjectUserId(principal)), +}) diff --git a/apps/sim/lib/platform-context/application/read-enterprise-context.ts b/apps/sim/lib/platform-context/application/read-enterprise-context.ts new file mode 100644 index 00000000000..dab02b17e5d --- /dev/null +++ b/apps/sim/lib/platform-context/application/read-enterprise-context.ts @@ -0,0 +1,88 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' +import { platformContextDelegationPolicy } from '@/lib/platform-context/application/authorization' +import { resolvePlatformContextWorkspace } from '@/lib/platform-context/application/context' +import { platformContextOperations } from '@/lib/platform-context/application/operations' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' + +const ENTERPRISE_PERMISSION_DOCUMENTATION = [ + { + title: 'Roles and permissions', + path: 'docs/platform/permissions.mdx', + url: 'https://docs.sim.ai/platform/permissions', + }, + { + title: 'Enterprise Access Control', + path: 'docs/platform/enterprise/access-control.mdx', + url: 'https://docs.sim.ai/platform/enterprise/access-control', + }, +] as const + +export interface ReadEnterpriseContextInput { + workspaceId: string +} + +export const readEnterpriseContext = defineAuthorizedWorkspaceUseCase({ + operation: platformContextOperations.readEnterpriseContext, + resolveContext: ({ input }: { input: ReadEnterpriseContextInput }) => + resolvePlatformContextWorkspace(input.workspaceId), + authorizationOptions: { delegation: platformContextDelegationPolicy }, + async execute({ principal, context }) { + const userId = requirePrincipalSubjectUserId(principal) + const hostContext = await getWorkspaceHostContextForViewer(context.workspaceId, userId) + if (!hostContext) { + throw new OrchestrationError('not_found', 'Workspace not found or you do not have access.') + } + + const accessControl = await resolveVerifiedUserAccessControlContext( + userId, + context.workspaceId, + hostContext.hostOrganizationId + ) + const canWrite = permissionSatisfies(hostContext.viewer.permission, 'write') + const canAdmin = permissionSatisfies(hostContext.viewer.permission, 'admin') + const allDeploymentSurfacesHidden = + accessControl.config?.hideDeployApi === true && + accessControl.config.hideDeployMcp === true && + accessControl.config.hideDeployChatbot === true + + return { + workspace: { + id: hostContext.workspace.id, + name: hostContext.workspace.name, + mode: hostContext.workspace.workspaceMode, + permission: hostContext.viewer.permission, + capabilities: { + canRead: true, + canEdit: canWrite, + canRun: true, + canDeploy: canAdmin && !allDeploymentSurfacesHidden, + canManageWorkspace: canAdmin, + }, + }, + organization: hostContext.hostOrganizationId + ? { + id: hostContext.hostOrganizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + } + : null, + accessControl: { + entitled: accessControl.entitled, + governingPermissionGroup: accessControl.permissionGroup, + effectiveConfig: accessControl.config, + activeRestrictions: getActivePermissionGroupRestrictions(accessControl.config), + }, + documentation: ENTERPRISE_PERMISSION_DOCUMENTATION, + resolvedAt: new Date().toISOString(), + } + }, +}) diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index c08bb7d7d39..6c83a34867d 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -85,6 +85,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'write', isHostOrganizationMember: true, isHostOrganizationAdmin: false, + organizationRole: 'member', }, }) ) @@ -104,6 +105,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'read', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }) expect(context?.hostOrganizationId).toBe('org-host') }) @@ -125,6 +127,7 @@ describe('getWorkspaceHostContextForViewer', () => { permission: 'admin', isHostOrganizationMember: false, isHostOrganizationAdmin: false, + organizationRole: null, }, }) ) diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index d350c5a1763..5df7df76461 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -25,7 +25,7 @@ async function resolveWorkspaceHostContextForViewer( getWorkspaceOwnerSubscriptionAccess(workspaceId), hostOrganizationId ? getOrganizationSettingsAccess(hostOrganizationId, userId) - : Promise.resolve({ isMember: false, isAdmin: false }), + : Promise.resolve({ role: null, isMember: false, isAdmin: false }), ]) return { @@ -41,6 +41,7 @@ async function resolveWorkspaceHostContextForViewer( permission: access.permission, isHostOrganizationMember: hostOrganizationAccess.isMember, isHostOrganizationAdmin: hostOrganizationAccess.isAdmin, + organizationRole: hostOrganizationAccess.role, }, } } diff --git a/package.json b/package.json index 81f5d23783b..2826be7b11c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "metrics-contract:check": "bun run scripts/sync-metrics-contract.ts --check", "vfs-snapshot-contract:generate": "bun run scripts/sync-vfs-snapshot-contract.ts", "vfs-snapshot-contract:check": "bun run scripts/sync-vfs-snapshot-contract.ts --check", + "docs-manifest:generate": "bun run scripts/sync-docs-manifest.ts", + "docs-manifest:check": "bun run scripts/sync-docs-manifest.ts --check", "mship:generate": "bun run scripts/generate-mship-contracts.ts", "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "library:covers": "bun run scripts/generate-library-covers.tsx", diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts new file mode 100644 index 00000000000..35a240bd836 --- /dev/null +++ b/scripts/sync-docs-manifest.ts @@ -0,0 +1,113 @@ +/** + * Generate the static docs manifest the copilot's `docs/` VFS tree is built from. + * + * Source of truth: `apps/docs/content/docs/en/**\/*.mdx` — the English docs + * corpus, whose folder structure mirrors the public docs.sim.ai URL structure. + * The copilot never reads those files from disk (they are not deployed with + * `apps/sim`); it globs this manifest for structure and fetches page content + * from the live site on demand. That makes the manifest the one thing that can + * drift, hence `--check` in CI. + * + * Path derivation (each entry is BOTH the `docs/`-relative VFS path and the + * docs.sim.ai URL path, so a read is a plain fetch of `https://docs.sim.ai/`): + * - `workflows/blocks/agent.mdx` → `workflows/blocks/agent.mdx` + * - `workflows/index.mdx` → `workflows.mdx` (fumadocs folds index pages + * into their parent URL; `/workflows/index.mdx` + * is a 404 on the site) + * + * Excluded, and intentionally absent from the VFS: every section in + * `UNMOUNTED_DOCS_SECTIONS` (fetch those with the scrape tool if ever needed), + * the root `index.mdx` (its URL is `/`, which redirects), and every non-`en` + * locale. + * + * Usage: + * bun run docs-manifest:generate # write the manifest + * bun run docs-manifest:check # fail (exit 1) if the manifest is stale + */ +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { foldDocsIndexPath, UNMOUNTED_DOCS_SECTIONS } from '../apps/sim/lib/copilot/docs/docs-path' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs/en') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') + +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * Shared with the vector search's unscoped filter so readability and + * findability cannot drift apart — see `UNMOUNTED_DOCS_SECTIONS`. + */ +const EXCLUDED_SECTIONS = new Set(UNMOUNTED_DOCS_SECTIONS) + +/** Collect every `.mdx` file under `dir`, as paths relative to {@link DOCS_CONTENT_DIR}. */ +async function collectMdxPaths(dir: string, prefix = ''): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (prefix === '' && EXCLUDED_SECTIONS.has(entry.name)) continue + paths.push(...(await collectMdxPaths(resolve(dir, entry.name), relative))) + continue + } + if (entry.isFile() && entry.name.endsWith('.mdx')) paths.push(relative) + } + return paths +} + +/** Map an `en`-relative mdx file path to its docs.sim.ai URL path, or null to drop it. */ +function toDocsPath(mdxPath: string): string | null { + if (mdxPath === 'index.mdx') return null + return foldDocsIndexPath(mdxPath) +} + +function render(paths: string[]): string { + const entries = paths.map((path) => ` '${path}',`).join('\n') + return `/** + * AUTO-GENERATED FILE. DO NOT EDIT. + * Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts. + * Run: bun run docs-manifest:generate. + * + * Every page in the copilot's read-only \`docs/\` VFS tree, as a path that is + * simultaneously the \`docs/\`-relative VFS path and the docs.sim.ai URL path + * (so \`docs/workflows/blocks/agent.mdx\` reads + * \`https://docs.sim.ai/workflows/blocks/agent.mdx\`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ +${entries} +] +` +} + +async function main() { + const checkOnly = process.argv.includes('--check') + + const mdxPaths = await collectMdxPaths(DOCS_CONTENT_DIR) + const docsPaths = mdxPaths + .map(toDocsPath) + .filter((path): path is string => path !== null) + .sort() + + if (docsPaths.length === 0) { + throw new Error(`No docs pages found under ${DOCS_CONTENT_DIR}`) + } + + const rendered = formatGeneratedSource(render(docsPaths), OUTPUT_PATH, ROOT) + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + 'Generated docs manifest is stale — the docs tree changed (page added, removed, or renamed). Run: bun run docs-manifest:generate' + ) + } + return + } + + await writeFile(OUTPUT_PATH, rendered, 'utf8') +} + +await main() From 73ff4b7568daaced98c51e9bbb003245fdea1aa7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 13:03:23 -0700 Subject: [PATCH 008/135] Align Copilot tools and resource handling --- .../components/agent-group/tool-call-item.tsx | 4 +- .../message-content/message-content.test.ts | 18 +- .../message-content/message-content.tsx | 4 +- .../home/components/message-content/utils.ts | 14 +- .../resource-content/resource-content.tsx | 1 + .../home/hooks/stream/handle-tool-event.ts | 17 +- .../home/hooks/stream/stream-helpers.ts | 48 +- .../hooks/stream/turn-model-serialize.test.ts | 20 +- .../home/hooks/stream/turn-model.test.ts | 54 +- .../home/hooks/stream/turn-model.ts | 30 +- .../app/workspace/[workspaceId]/home/types.ts | 7 +- .../[workspaceId]/tables/[tableId]/table.tsx | 12 +- apps/sim/lib/api/contracts/custom-blocks.ts | 2 +- apps/sim/lib/billing/core/subscription.ts | 2 +- .../application/table-commands.test.ts | 2 +- .../lib/copilot/async-runs/repository.test.ts | 4 +- apps/sim/lib/copilot/chat/display-message.ts | 10 +- .../copilot/chat/effective-transcript.test.ts | 4 +- apps/sim/lib/copilot/chat/payload.test.ts | 4 +- apps/sim/lib/copilot/chat/payload.ts | 6 +- .../lib/copilot/generated/tool-catalog-v1.ts | 2244 +++++++++-------- .../lib/copilot/generated/tool-schemas-v1.ts | 1986 ++++++++------- .../copilot/request/context/result.test.ts | 10 +- .../request/go/file-preview-adapter.test.ts | 8 +- .../request/go/file-preview-adapter.ts | 41 +- .../sim/lib/copilot/request/go/stream.test.ts | 22 +- .../copilot/request/handlers/handlers.test.ts | 18 +- apps/sim/lib/copilot/request/handlers/tool.ts | 4 +- .../copilot/request/session/contract.test.ts | 2 +- .../lib/copilot/request/session/contract.ts | 12 +- .../lib/copilot/request/session/event.test.ts | 4 +- .../copilot/request/session/writer.test.ts | 2 +- .../sim/lib/copilot/request/sse-utils.test.ts | 2 +- .../copilot/request/tools/executor.test.ts | 2 +- .../sim/lib/copilot/request/tools/executor.ts | 48 +- .../lib/copilot/request/tools/files.test.ts | 44 +- apps/sim/lib/copilot/request/tools/files.ts | 10 +- .../copilot/request/tools/permission.test.ts | 20 +- .../lib/copilot/request/tools/permissions.ts | 4 +- .../tools/resolved-secret-result.test.ts | 4 +- .../lib/copilot/request/tools/tables.test.ts | 16 +- apps/sim/lib/copilot/request/tools/tables.ts | 4 +- apps/sim/lib/copilot/request/types.ts | 2 +- .../lib/copilot/resources/extraction.test.ts | 18 +- apps/sim/lib/copilot/resources/extraction.ts | 40 +- apps/sim/lib/copilot/resources/types.ts | 2 + .../copilot/tool-executor/executor.test.ts | 46 +- .../sim/lib/copilot/tool-executor/executor.ts | 2 +- .../tool-executor/register-handlers.ts | 40 +- .../copilot/tools/client/store-utils.test.ts | 4 +- .../tools/handlers/deployment/context.test.ts | 2 +- .../handlers/deployment/custom-block.test.ts | 2 +- .../tools/handlers/deployment/custom-block.ts | 2 +- .../tools/handlers/deployment/deploy.test.ts | 4 +- .../tools/handlers/deployment/deploy.ts | 6 +- .../tools/handlers/deployment/manage.ts | 3 +- .../tools/handlers/function-execute.test.ts | 20 +- .../tools/handlers/function-execute.ts | 8 +- .../handlers/management/manage-custom-tool.ts | 2 +- .../handlers/management/manage-mcp-tool.ts | 9 +- .../tools/handlers/materialize-file.test.ts | 8 +- .../tools/handlers/materialize-file.ts | 14 +- .../lib/copilot/tools/handlers/param-types.ts | 3 + .../tools/handlers/platform-actions.ts | 2 +- .../copilot/tools/handlers/resources.test.ts | 55 + .../lib/copilot/tools/handlers/resources.ts | 22 +- .../lib/copilot/tools/handlers/run-code.ts | 2 +- .../tools/handlers/upload-file-reader.test.ts | 6 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 4 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 2 +- .../sim/lib/copilot/tools/permissions.test.ts | 4 +- .../registry/server-tool-adapter.test.ts | 10 +- .../sim/lib/copilot/tools/server/base-tool.ts | 2 +- .../server/docs/search-documentation.test.ts | 2 +- .../tools/server/docs/search-documentation.ts | 4 +- .../tools/server/enrichment/enrichment-run.ts | 4 +- .../copilot/tools/server/files/create-file.ts | 9 +- .../copilot/tools/server/files/doc-compile.ts | 2 +- .../files/download-to-workspace-file.ts | 4 +- .../tools/server/files/edit-content.ts | 14 +- .../server/files/file-intent-store.test.ts | 4 +- .../tools/server/files/file-intent-store.ts | 2 +- .../tools/server/files/file-preview.ts | 6 +- .../tools/server/files/workspace-file.ts | 14 +- .../server/knowledge/knowledge-base.test.ts | 6 +- .../tools/server/knowledge/knowledge-base.ts | 6 +- .../server/knowledge/search-knowledge-base.ts | 4 +- .../tools/server/other/search-online.test.ts | 2 +- .../tools/server/other/search-online.ts | 4 +- apps/sim/lib/copilot/tools/server/router.ts | 22 +- .../tools/server/table/table-views.test.ts | 123 + .../copilot/tools/server/table/table-views.ts | 197 ++ .../tools/server/table/user-table.test.ts | 4 +- .../copilot/tools/server/table/user-table.ts | 54 +- .../lib/copilot/tools/tool-display.test.ts | 60 +- apps/sim/lib/copilot/tools/tool-display.ts | 66 +- apps/sim/lib/copilot/vfs/resource-writer.ts | 2 +- apps/sim/lib/copilot/vfs/serializers.ts | 35 + apps/sim/lib/copilot/vfs/workspace-vfs.ts | 37 +- .../lib/folders/application/resource-vfs.ts | Bin 14692 -> 14713 bytes .../mcp/application/workflow-deployments.ts | 2 +- apps/sim/lib/mcp/workflow-mcp-sync.ts | 2 +- apps/sim/lib/table/application/views.ts | 4 +- .../application/workspace-file-imports.ts | 2 +- apps/sim/lib/table/views/service.test.ts | 49 + apps/sim/lib/table/views/service.ts | 82 + apps/sim/lib/uploads/archive.test.ts | 2 +- apps/sim/lib/uploads/archive.ts | 2 +- .../workspace/track-chat-upload.test.ts | 2 +- .../workspace/workspace-file-manager.ts | 2 +- apps/sim/lib/uploads/utils/file-utils.ts | 2 +- apps/sim/lib/uploads/utils/validation.ts | 4 +- .../lib/workflows/custom-blocks/operations.ts | 2 +- .../orchestration/chat-deploy.test.ts | 2 +- .../workflows/orchestration/chat-deploy.ts | 8 +- 115 files changed, 3396 insertions(+), 2574 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index b00b5bc043a..2a06b33e197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -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+)"/) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 8226865967a..a777c7006a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -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() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 4b230fb4fd3..24dfc3fd842 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -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 = { - [FILE_SUBAGENT_ID]: WorkspaceFile.id, + [FILE_SUBAGENT_ID]: PrepareFileEdit.id, } function isToolResultRead(params?: Record): boolean { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 8079fc40de6..16179983449 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -33,19 +33,19 @@ const TOOL_ICONS: Record = { 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, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 4378ba9a3cf..48f3769b17b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -256,6 +256,7 @@ export const ResourceContent = memo(function ResourceContent({ tableId={resource.id} embedded viewsEnabled={tableViewsEnabled} + initialViewId={resource.viewId} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index e00e851626d..2f23df785b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -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 | 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. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 90e8d0f9cac..2abb9ef6a7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -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 = 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', diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts index d4a5b69e3aa..a764b32fa9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts @@ -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' } ), ]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 4681ae8e402..971ecd57b85 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -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') diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 6ac6b32e3f3..d636bf8b470 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -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 @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index e6d21c27765..f29c7b86125 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -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 = { 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', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 8db321108dc..d9f04de5a17 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -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. diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index 70f40a1132f..99ec47a5f00 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -83,7 +83,7 @@ export const listCustomBlocksQuerySchema = z.object({ * Icon URLs are rendered as org-wide `` 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/') diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 44345553e4d..50c11d0ab20 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -702,7 +702,7 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise { try { diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts index 900f8b76dfe..f1d734dec1e 100644 --- a/apps/sim/lib/copilot/application/table-commands.test.ts +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -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' } }, }, diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/copilot/async-runs/repository.test.ts index e6098a8b851..81bdd061986 100644 --- a/apps/sim/lib/copilot/async-runs/repository.test.ts +++ b/apps/sim/lib/copilot/async-runs/repository.test.ts @@ -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', }) diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 24df088f595..443d517a7bb 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -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. */ diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts index 10c74da0545..23910ed66ce 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts @@ -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, }), }), diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 2f061cf8592..4ec333268bd 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -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'), }, ]) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index ef1ea48d159..5f185b683f3 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -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")` ) } } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 3d522d70351..82ec12ead2a 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -7,7 +7,7 @@ export interface ToolCatalogEntry { clientExecutable?: boolean hidden?: boolean id: - | 'agent' + | 'apply_file_edit' | 'auth' | 'browser' | 'browser_click' @@ -32,26 +32,21 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' - | 'check_deployment_status' | 'cp' - | 'crawl_website' - | 'create_file' + | 'create_empty_file' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' | 'deploy' - | 'deploy_api' - | 'deploy_chat' - | 'deploy_custom_block' - | 'deploy_mcp' + | 'deploy_as_api' + | 'deploy_as_chat' + | 'deploy_as_mcp' | 'diff_workflows' - | 'download_to_workspace_file' - | 'edit_content' + | 'download_file' | 'edit_workflow' - | 'enrichment_run' + | 'extensions' | 'ffmpeg' | 'file' - | 'function_execute' | 'generate_api_key' | 'generate_audio' | 'generate_image' @@ -59,15 +54,14 @@ export interface ToolCatalogEntry { | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_log' - | 'get_page_contents' - | 'get_platform_actions' + | 'get_deployment_status' + | 'get_ui_reference' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' | 'knowledge' - | 'knowledge_base' + | 'list_deployment_versions' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -76,17 +70,19 @@ export interface ToolCatalogEntry { | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_mcp_tool' + | 'manage_knowledge_base' + | 'manage_mcp_connection' | 'manage_sandbox' | 'manage_skill' - | 'materialize_file' | 'media' | 'mkdir' | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'prepare_file_edit' | 'promote_to_live' + | 'publish_custom_block' | 'query_logs' | 'query_user_table' | 'read' @@ -97,17 +93,17 @@ export interface ToolCatalogEntry { | 'run' | 'run_block' | 'run_code' + | 'run_enrichment' | 'run_from_block' + | 'run_function' | 'run_workflow' | 'run_workflow_until_block' - | 'scrape_page' + | 'save_upload' | 'search' - | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' - | 'search_online' - | 'search_patterns' + | 'search_sim_docs' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -118,17 +114,21 @@ export interface ToolCatalogEntry { | 'table_enrichments' | 'table_manage' | 'table_rows' + | 'table_views' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'web_crawl' + | 'web_fetch' + | 'web_scrape' + | 'web_search' | 'workflow' - | 'workspace_file' internal?: boolean mode: 'async' | 'sync' name: - | 'agent' + | 'apply_file_edit' | 'auth' | 'browser' | 'browser_click' @@ -153,26 +153,21 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' - | 'check_deployment_status' | 'cp' - | 'crawl_website' - | 'create_file' + | 'create_empty_file' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_workspace_mcp_server' | 'deploy' - | 'deploy_api' - | 'deploy_chat' - | 'deploy_custom_block' - | 'deploy_mcp' + | 'deploy_as_api' + | 'deploy_as_chat' + | 'deploy_as_mcp' | 'diff_workflows' - | 'download_to_workspace_file' - | 'edit_content' + | 'download_file' | 'edit_workflow' - | 'enrichment_run' + | 'extensions' | 'ffmpeg' | 'file' - | 'function_execute' | 'generate_api_key' | 'generate_audio' | 'generate_image' @@ -180,15 +175,14 @@ export interface ToolCatalogEntry { | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_log' - | 'get_page_contents' - | 'get_platform_actions' + | 'get_deployment_status' + | 'get_ui_reference' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' | 'grep' | 'knowledge' - | 'knowledge_base' + | 'list_deployment_versions' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -197,17 +191,19 @@ export interface ToolCatalogEntry { | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_mcp_tool' + | 'manage_knowledge_base' + | 'manage_mcp_connection' | 'manage_sandbox' | 'manage_skill' - | 'materialize_file' | 'media' | 'mkdir' | 'mv' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'prepare_file_edit' | 'promote_to_live' + | 'publish_custom_block' | 'query_logs' | 'query_user_table' | 'read' @@ -218,17 +214,17 @@ export interface ToolCatalogEntry { | 'run' | 'run_block' | 'run_code' + | 'run_enrichment' | 'run_from_block' + | 'run_function' | 'run_workflow' | 'run_workflow_until_block' - | 'scrape_page' + | 'save_upload' | 'search' - | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' - | 'search_online' - | 'search_patterns' + | 'search_sim_docs' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' @@ -239,13 +235,17 @@ export interface ToolCatalogEntry { | 'table_enrichments' | 'table_manage' | 'table_rows' + | 'table_views' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'web_crawl' + | 'web_fetch' + | 'web_scrape' + | 'web_search' | 'workflow' - | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' requiresApproval?: boolean @@ -265,20 +265,35 @@ export interface ToolCatalogEntry { | 'workflow' } -export const Agent: ToolCatalogEntry = { - id: 'agent', - name: 'agent', - route: 'subagent', +export const ApplyFileEdit: ToolCatalogEntry = { + id: 'apply_file_edit', + name: 'apply_file_edit', + route: 'sim', mode: 'async', parameters: { + type: 'object', properties: { - request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, + content: { + type: 'string', + description: + 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', + }, }, - required: ['request'], + required: ['content'], + }, + resultSchema: { type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { type: 'string', description: 'Human-readable summary of the outcome.' }, + success: { type: 'boolean', description: 'Whether the content was applied successfully.' }, + }, + required: ['success', 'message'], }, - subagentId: 'agent', - internal: true, requiredPermission: 'write', } @@ -1244,22 +1259,6 @@ export const CallIntegrationTool: ToolCatalogEntry = { requiresApproval: true, } -export const CheckDeploymentStatus: ToolCatalogEntry = { - id: 'check_deployment_status', - name: 'check_deployment_status', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'Workflow ID to check (defaults to current workflow)', - }, - }, - }, -} - export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -1290,35 +1289,9 @@ export const Cp: ToolCatalogEntry = { requiredPermission: 'write', } -export const CrawlWebsite: ToolCatalogEntry = { - id: 'crawl_website', - name: 'crawl_website', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - exclude_paths: { - type: 'array', - description: 'Skip URLs matching these patterns', - items: { type: 'string' }, - }, - include_paths: { - type: 'array', - description: 'Only crawl URLs matching these patterns', - items: { type: 'string' }, - }, - limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' }, - max_depth: { type: 'number', description: 'How deep to follow links (default 2)' }, - url: { type: 'string', description: 'Starting URL to crawl from' }, - }, - required: ['url'], - }, -} - -export const CreateFile: ToolCatalogEntry = { - id: 'create_file', - name: 'create_file', +export const CreateEmptyFile: ToolCatalogEntry = { + id: 'create_empty_file', + name: 'create_empty_file', route: 'sim', mode: 'async', parameters: { @@ -1470,9 +1443,9 @@ export const Deploy: ToolCatalogEntry = { internal: true, } -export const DeployApi: ToolCatalogEntry = { - id: 'deploy_api', - name: 'deploy_api', +export const DeployAsApi: ToolCatalogEntry = { + id: 'deploy_as_api', + name: 'deploy_as_api', route: 'sim', mode: 'async', parameters: { @@ -1521,7 +1494,7 @@ export const DeployApi: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -1551,9 +1524,9 @@ export const DeployApi: ToolCatalogEntry = { requiresApproval: true, } -export const DeployChat: ToolCatalogEntry = { - id: 'deploy_chat', - name: 'deploy_chat', +export const DeployAsChat: ToolCatalogEntry = { + id: 'deploy_as_chat', + name: 'deploy_as_chat', route: 'sim', mode: 'async', parameters: { @@ -1652,7 +1625,7 @@ export const DeployChat: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_chat this is always "chat".', + 'Deployment surface this result describes. For deploy_as_chat this is always "chat".', }, examples: { type: 'object', @@ -1670,7 +1643,7 @@ export const DeployChat: ToolCatalogEntry = { }, success: { type: 'boolean', - description: 'Whether the deploy_chat action completed successfully.', + description: 'Whether the deploy_as_chat action completed successfully.', }, version: { type: 'number', @@ -1697,121 +1670,9 @@ export const DeployChat: ToolCatalogEntry = { requiresApproval: true, } -export const DeployCustomBlock: ToolCatalogEntry = { - id: 'deploy_custom_block', - name: 'deploy_custom_block', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', - enum: ['deploy', 'undeploy'], - default: 'deploy', - }, - description: { - type: 'string', - description: 'Short description shown in the block picker, max 280 characters', - }, - exposedOutputs: { - type: 'array', - description: - "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", - items: { - type: 'object', - properties: { - blockId: { type: 'string', description: 'Block UUID inside the workflow' }, - name: { type: 'string', description: 'Friendly output name shown on the block' }, - path: { - type: 'string', - description: - "Dot-path into that block's output (from get_block_outputs relativeOutputs)", - }, - }, - required: ['blockId', 'path', 'name'], - }, - }, - iconUrl: { - type: 'string', - description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', - }, - inputs: { - type: 'array', - description: - "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'Stable id of the input trigger field' }, - placeholder: { - type: 'string', - description: "Placeholder text shown in the block's input field", - }, - }, - required: ['id'], - }, - }, - name: { - type: 'string', - description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', - }, - workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, - }, - }, - resultSchema: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action performed by the tool, such as "deploy" or "undeploy".', - }, - blockId: { type: 'string', description: 'Custom block record ID.' }, - blockType: { - type: 'string', - description: 'Stable block type slug (custom_block_*) used in workflow state.', - }, - deploymentConfig: { - type: 'object', - description: - "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", - }, - deploymentStatus: { - type: 'object', - description: - 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', - }, - deploymentType: { - type: 'string', - description: - 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', - }, - isDeployed: { - type: 'boolean', - description: 'Whether the custom block is published after this tool call.', - }, - name: { type: 'string', description: 'Display name of the custom block.' }, - removed: { - type: 'boolean', - description: 'Whether the custom block was unpublished during an undeploy action.', - }, - updated: { - type: 'boolean', - description: 'Whether an existing custom block was updated instead of created.', - }, - workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, - }, - required: ['deploymentType', 'deploymentStatus'], - }, - requiredPermission: 'admin', -} - -export const DeployMcp: ToolCatalogEntry = { - id: 'deploy_mcp', - name: 'deploy_mcp', +export const DeployAsMcp: ToolCatalogEntry = { + id: 'deploy_as_mcp', + name: 'deploy_as_mcp', route: 'sim', mode: 'async', parameters: { @@ -1873,7 +1734,7 @@ export const DeployMcp: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_mcp this is always "mcp".', + 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".', }, examples: { type: 'object', @@ -1935,9 +1796,9 @@ export const DiffWorkflows: ToolCatalogEntry = { }, } -export const DownloadToWorkspaceFile: ToolCatalogEntry = { - id: 'download_to_workspace_file', - name: 'download_to_workspace_file', +export const DownloadFile: ToolCatalogEntry = { + id: 'download_file', + name: 'download_file', route: 'sim', mode: 'async', parameters: { @@ -1990,38 +1851,6 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { capabilities: ['file_output'], } -export const EditContent: ToolCatalogEntry = { - id: 'edit_content', - name: 'edit_content', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: - 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', - }, - }, - required: ['content'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { type: 'string', description: 'Human-readable summary of the outcome.' }, - success: { type: 'boolean', description: 'Whether the content was applied successfully.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const EditWorkflow: ToolCatalogEntry = { id: 'edit_workflow', name: 'edit_workflow', @@ -2066,53 +1895,20 @@ export const EditWorkflow: ToolCatalogEntry = { requiredPermission: 'write', } -export const EnrichmentRun: ToolCatalogEntry = { - id: 'enrichment_run', - name: 'enrichment_run', - route: 'sim', +export const Extensions: ToolCatalogEntry = { + id: 'extensions', + name: 'extensions', + route: 'subagent', mode: 'async', parameters: { - type: 'object', properties: { - enrichmentId: { - type: 'string', - description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", - enum: [ - 'work-email', - 'phone-number', - 'company-domain', - 'company-info', - 'email-verification', - ], - }, - inputs: { - type: 'object', - description: - 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', - }, + request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, }, - required: ['enrichmentId', 'inputs'], - }, - resultSchema: { + required: ['request'], type: 'object', - properties: { - matched: { - type: 'boolean', - description: 'True when a provider returned a non-empty result.', - }, - provider: { - type: ['string', 'null'], - description: - 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', - }, - result: { - type: 'object', - description: 'Mapped output values from the winning provider (empty object on no match).', - }, - }, - required: ['matched', 'result'], }, + subagentId: 'agent', + internal: true, requiredPermission: 'write', } @@ -2302,168 +2098,17 @@ export const File: ToolCatalogEntry = { internal: true, } -export const FunctionExecute: ToolCatalogEntry = { - id: 'function_execute', - name: 'function_execute', +export const GenerateApiKey: ToolCatalogEntry = { + id: 'generate_api_key', + name: 'generate_api_key', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - code: { + name: { type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', - }, - inputs: { - type: 'object', - description: - 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', - properties: { - directories: { - type: 'array', - description: - 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', - }, - }, - required: ['path'], - }, - }, - files: { - type: 'array', - description: 'Workspace files to mount into the sandbox.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', - }, - }, - required: ['path'], - }, - }, - tables: { - type: 'array', - description: 'Workspace tables to mount as CSV files.', - items: { - type: 'object', - properties: { - path: { type: 'string', description: 'Canonical VFS table path when available.' }, - sandboxPath: { - type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', - }, - tableId: { type: 'string', description: 'Workspace table ID.' }, - }, - }, - }, - }, - }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], - }, - outputTable: { - type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', - }, - outputs: { - type: 'object', - description: - 'Workspace files to create or overwrite from returned code results or sandbox-created files.', - properties: { - files: { - type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', - items: { - type: 'object', - properties: { - format: { - type: 'string', - description: 'Optional serialization format for returned values.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - mimeType: { - type: 'string', - description: 'Optional MIME type override when inference is not enough.', - }, - mode: { - type: 'string', - description: 'Create a new file or overwrite an existing file at path.', - enum: ['create', 'overwrite'], - }, - path: { - type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', - }, - }, - required: ['path', 'mode'], - }, - }, - }, - }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, - timeout: { - type: 'number', - description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', - default: 10, - }, - title: { - type: 'string', - description: - 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', - }, - }, - required: ['code'], - }, - requiredPermission: 'write', - requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], -} - -export const GenerateApiKey: ToolCatalogEntry = { - id: 'generate_api_key', - name: 'generate_api_key', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: "A descriptive name for the API key (e.g., 'production-key', 'dev-testing').", + description: "A descriptive name for the API key (e.g., 'production-key', 'dev-testing').", }, workspaceId: { type: 'string', @@ -2983,9 +2628,9 @@ export const GetDeployedWorkflowState: ToolCatalogEntry = { }, } -export const GetDeploymentLog: ToolCatalogEntry = { - id: 'get_deployment_log', - name: 'get_deployment_log', +export const GetDeploymentStatus: ToolCatalogEntry = { + id: 'get_deployment_status', + name: 'get_deployment_status', route: 'sim', mode: 'async', parameters: { @@ -2993,42 +2638,15 @@ export const GetDeploymentLog: ToolCatalogEntry = { properties: { workflowId: { type: 'string', - description: 'Optional workflow ID. If not provided, uses the current workflow in context.', - }, - }, - }, -} - -export const GetPageContents: ToolCatalogEntry = { - id: 'get_page_contents', - name: 'get_page_contents', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - include_highlights: { - type: 'boolean', - description: 'Include key highlights (default false)', - }, - include_summary: { - type: 'boolean', - description: 'Include AI-generated summary (default false)', - }, - include_text: { type: 'boolean', description: 'Include full page text (default true)' }, - urls: { - type: 'array', - description: 'URLs to get content from (max 10)', - items: { type: 'string' }, + description: 'Workflow ID to check (defaults to current workflow)', }, }, - required: ['urls'], }, } -export const GetPlatformActions: ToolCatalogEntry = { - id: 'get_platform_actions', - name: 'get_platform_actions', +export const GetUiReference: ToolCatalogEntry = { + id: 'get_ui_reference', + name: 'get_ui_reference', route: 'sim', mode: 'async', parameters: { type: 'object', properties: {} }, @@ -3160,203 +2778,19 @@ export const Knowledge: ToolCatalogEntry = { internal: true, } -export const KnowledgeBase: ToolCatalogEntry = { - id: 'knowledge_base', - name: 'knowledge_base', +export const ListDeploymentVersions: ToolCatalogEntry = { + id: 'list_deployment_versions', + name: 'list_deployment_versions', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - apiKey: { - type: 'string', - description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', - }, - chunkingConfig: { - type: 'object', - description: "Chunking configuration (optional for 'create')", - properties: { - maxSize: { - type: 'number', - description: 'Maximum chunk size (100-4000, default: 1024)', - default: 1024, - }, - minSize: { - type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', - default: 1, - }, - overlap: { - type: 'number', - description: 'Overlap between chunks (0-500, default: 200)', - default: 200, - }, - }, - }, - connectorId: { - type: 'string', - description: - 'Connector ID (required for update_connector, delete_connector, sync_connector)', - }, - connectorStatus: { - type: 'string', - description: 'Connector status (optional for update_connector)', - enum: ['active', 'paused'], - }, - connectorType: { - type: 'string', - description: - "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", - }, - credentialId: { - type: 'string', - description: - 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', - }, - description: { - type: 'string', - description: "Description of the knowledge base (optional for 'create')", - }, - disabledTagIds: { - type: 'array', - description: - 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', - }, - documentId: { type: 'string', description: 'Document ID (required for update_document)' }, - documentIds: { - type: 'array', - description: 'Document IDs (for batch delete_document)', - items: { type: 'string' }, - }, - enabled: { - type: 'boolean', - description: 'Enable/disable a document (optional for update_document)', - }, - filePaths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', - items: { type: 'string' }, - }, - filename: { - type: 'string', - description: 'New filename for a document (optional for update_document)', - }, - knowledgeBaseId: { - type: 'string', - description: - 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', - }, - knowledgeBaseIds: { - type: 'array', - description: 'Knowledge base IDs (for batch delete)', - items: { type: 'string' }, - }, - name: { - type: 'string', - description: "Name of the knowledge base (required for 'create')", - }, - query: { type: 'string', description: "Search query text (required for 'query')" }, - sourceConfig: { - type: 'object', - description: - 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', - }, - syncIntervalMinutes: { - type: 'number', - description: - 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', - default: 1440, - }, - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID (required for update_tag, delete_tag)', - }, - tagDisplayName: { - type: 'string', - description: - 'Display name for the tag (required for create_tag, optional for update_tag)', - }, - tagFieldType: { - type: 'string', - description: - 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', - enum: ['text', 'number', 'date', 'boolean'], - }, - tagValues: { - type: 'array', - description: - 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', - items: { - type: 'object', - properties: { - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID returned by list_tags.', - }, - value: { - type: ['string', 'number', 'boolean', 'null'], - description: - "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", - }, - }, - required: ['tagDefinitionId', 'value'], - }, - }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - workspaceId: { - type: 'string', - description: - "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", - }, - }, - }, - operation: { + workflowId: { type: 'string', - description: 'The operation to perform', - enum: [ - 'create', - 'get', - 'query', - 'add_file', - 'update', - 'delete_document', - 'update_document', - 'list_tags', - 'create_tag', - 'update_tag', - 'delete_tag', - 'get_tag_usage', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - ], - }, - }, - required: ['operation'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', }, - message: { type: 'string', description: 'Human-readable outcome summary.' }, - success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, - required: ['success', 'message'], }, } @@ -3542,25 +2976,225 @@ export const ManageCustomTool: ToolCatalogEntry = { }, required: ['type', 'function'], }, - toolId: { - type: 'string', + toolId: { + type: 'string', + description: + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { type: 'string' }, + }, + }, + required: ['operation'], + }, + requiredPermission: 'write', +} + +export const ManageKnowledgeBase: ToolCatalogEntry = { + id: 'manage_knowledge_base', + name: 'manage_knowledge_base', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + apiKey: { + type: 'string', + description: + 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + }, + chunkingConfig: { + type: 'object', + description: "Chunking configuration (optional for 'create')", + properties: { + maxSize: { + type: 'number', + description: 'Maximum chunk size (100-4000, default: 1024)', + default: 1024, + }, + minSize: { + type: 'number', + description: 'Minimum chunk size (1-2000, default: 1)', + default: 1, + }, + overlap: { + type: 'number', + description: 'Overlap between chunks (0-500, default: 200)', + default: 200, + }, + }, + }, + connectorId: { + type: 'string', + description: + 'Connector ID (required for update_connector, delete_connector, sync_connector)', + }, + connectorStatus: { + type: 'string', + description: 'Connector status (optional for update_connector)', + enum: ['active', 'paused'], + }, + connectorType: { + type: 'string', + description: + "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", + }, + credentialId: { + type: 'string', + description: + 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', + }, + description: { + type: 'string', + description: "Description of the knowledge base (optional for 'create')", + }, + disabledTagIds: { + type: 'array', + description: + 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + }, + documentId: { type: 'string', description: 'Document ID (required for update_document)' }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { type: 'string' }, + }, + enabled: { + type: 'boolean', + description: 'Enable/disable a document (optional for update_document)', + }, + filePaths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', + items: { type: 'string' }, + }, + filename: { + type: 'string', + description: 'New filename for a document (optional for update_document)', + }, + knowledgeBaseId: { + type: 'string', + description: + 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', + }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { type: 'string' }, + }, + name: { + type: 'string', + description: "Name of the knowledge base (required for 'create')", + }, + query: { type: 'string', description: "Search query text (required for 'query')" }, + sourceConfig: { + type: 'object', + description: + 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', + }, + syncIntervalMinutes: { + type: 'number', + description: + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + default: 1440, + }, + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID (required for update_tag, delete_tag)', + }, + tagDisplayName: { + type: 'string', + description: + 'Display name for the tag (required for create_tag, optional for update_tag)', + }, + tagFieldType: { + type: 'string', + description: + 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', + enum: ['text', 'number', 'date', 'boolean'], + }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + workspaceId: { + type: 'string', + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", + }, + }, + }, + operation: { + type: 'string', + description: 'The operation to perform', + enum: [ + 'create', + 'get', + 'query', + 'add_file', + 'update', + 'delete_document', + 'update_document', + 'list_tags', + 'create_tag', + 'update_tag', + 'delete_tag', + 'get_tag_usage', + 'add_connector', + 'update_connector', + 'delete_connector', + 'sync_connector', + ], + }, + }, + required: ['operation'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: ['object', 'array'], description: - "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", - }, - toolIds: { - type: 'array', - description: 'Array of custom tool IDs (for batch delete)', - items: { type: 'string' }, + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, - required: ['operation'], + required: ['success', 'message'], }, - requiredPermission: 'write', } -export const ManageMcpTool: ToolCatalogEntry = { - id: 'manage_mcp_tool', - name: 'manage_mcp_tool', +export const ManageMcpConnection: ToolCatalogEntry = { + id: 'manage_mcp_connection', + name: 'manage_mcp_connection', route: 'sim', mode: 'async', parameters: { @@ -3700,33 +3334,6 @@ export const ManageSkill: ToolCatalogEntry = { requiredPermission: 'write', } -export const MaterializeFile: ToolCatalogEntry = { - id: 'materialize_file', - name: 'materialize_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - fileNames: { - type: 'array', - description: - 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', - items: { type: 'string' }, - }, - operation: { - type: 'string', - description: - 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', - enum: ['save', 'import', 'extract'], - default: 'save', - }, - }, - required: ['fileNames'], - }, - requiredPermission: 'write', -} - export const Media: ToolCatalogEntry = { id: 'media', name: 'media', @@ -3868,6 +3475,11 @@ export const OpenResource: ToolCatalogEntry = { description: 'The resource type.', enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, + view: { + type: 'string', + description: + 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + }, }, required: ['type'], }, @@ -3877,6 +3489,129 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const PrepareFileEdit: ToolCatalogEntry = { + id: 'prepare_file_edit', + name: 'prepare_file_edit', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'The file operation to perform.', + enum: ['append', 'update', 'patch'], + }, + target: { + type: 'object', + description: 'Explicit file target. Use kind=path + path for existing files.', + properties: { + kind: { + type: 'string', + description: 'How the file target is identified.', + enum: ['path'], + }, + path: { + type: 'string', + description: + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', + }, + }, + required: ['kind'], + }, + title: { + type: 'string', + description: + 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + }, + contentType: { + type: 'string', + description: + 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + enum: [ + 'text/markdown', + 'text/html', + 'text/plain', + 'application/json', + 'text/csv', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + ], + }, + edit: { + type: 'object', + description: + 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired apply_file_edit tool call.', + properties: { + after_anchor: { + type: 'string', + description: + 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', + }, + anchor: { + type: 'string', + description: + 'Anchor line after which new content is inserted. Required for mode=insert_after.', + }, + before_anchor: { + type: 'string', + description: + 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', + }, + end_anchor: { + type: 'string', + description: 'First line to keep after deletion. Required for mode=delete_between.', + }, + mode: { + type: 'string', + description: 'Anchored edit mode when strategy=anchored.', + enum: ['replace_between', 'insert_after', 'delete_between'], + }, + occurrence: { + type: 'number', + description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', + }, + replaceAll: { + type: 'boolean', + description: + 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', + }, + search: { + type: 'string', + description: + 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', + }, + start_anchor: { + type: 'string', + description: 'First line to delete. Required for mode=delete_between.', + }, + strategy: { + type: 'string', + description: 'Patch strategy.', + enum: ['search_replace', 'anchored'], + }, + }, + }, + }, + required: ['operation', 'target', 'title'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { type: 'string', description: 'Human-readable summary of the outcome.' }, + success: { type: 'boolean', description: 'Whether the file operation succeeded.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + export const PromoteToLive: ToolCatalogEntry = { id: 'promote_to_live', name: 'promote_to_live', @@ -3888,17 +3623,129 @@ export const PromoteToLive: ToolCatalogEntry = { version: { type: 'number', description: - 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + requiredPermission: 'admin', + requiresApproval: true, +} + +export const PublishCustomBlock: ToolCatalogEntry = { + id: 'publish_custom_block', + name: 'publish_custom_block', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Block UUID inside the workflow' }, + name: { type: 'string', description: 'Friendly output name shown on the block' }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Stable id of the input trigger field' }, + placeholder: { + type: 'string', + description: "Placeholder text shown in the block's input field", + }, + }, + required: ['id'], + }, + }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + }, + workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, + }, + }, + resultSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { type: 'string', description: 'Custom block record ID.' }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', + description: + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", }, - workflowId: { + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { type: 'string', - description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + description: + 'Deployment surface this result describes. For publish_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { type: 'string', description: 'Display name of the custom block.' }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', }, + workflowId: { type: 'string', description: 'Workflow ID the custom block is bound to.' }, }, - required: ['version'], + required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', - requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -4040,6 +3887,11 @@ export const QueryUserTable: ToolCatalogEntry = { }, rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, + view: { + type: 'string', + description: + "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + }, }, }, operation: { @@ -4132,7 +3984,7 @@ export const Redeploy: ToolCatalogEntry = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -4358,64 +4210,265 @@ export const RunCode: ToolCatalogEntry = { path: { type: 'string', description: 'Canonical VFS table path when available.' }, sandboxPath: { type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + requiresApproval: true, + capabilities: ['file_input', 'directory_input', 'table_input'], +} + +export const RunEnrichment: ToolCatalogEntry = { + id: 'run_enrichment', + name: 'run_enrichment', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + enrichmentId: { + type: 'string', + description: + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", + enum: [ + 'work-email', + 'phone-number', + 'company-domain', + 'company-info', + 'email-verification', + ], + }, + inputs: { + type: 'object', + description: + 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', + }, + }, + required: ['enrichmentId', 'inputs'], + }, + resultSchema: { + type: 'object', + properties: { + matched: { + type: 'boolean', + description: 'True when a provider returned a non-empty result.', + }, + provider: { + type: ['string', 'null'], + description: + 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', + }, + result: { + type: 'object', + description: 'Mapped output values from the winning provider (empty object on no match).', + }, + }, + required: ['matched', 'result'], + }, + requiredPermission: 'write', +} + +export const RunFromBlock: ToolCatalogEntry = { + id: 'run_from_block', + name: 'run_from_block', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + executionId: { + type: 'string', + description: + 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + }, + startBlockId: { type: 'string', description: 'The block ID to start execution from.' }, + useDeployedState: { + type: 'boolean', + description: + 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + }, + workflowId: { + type: 'string', + description: + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', + }, + workflow_input: { + type: 'object', + description: 'JSON object with key-value mappings where each key is an input field name', + }, + }, + required: ['workflowId', 'startBlockId'], + }, + clientExecutable: true, +} + +export const RunFunction: ToolCatalogEntry = { + id: 'run_function', + name: 'run_function', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + outputTable: { + type: 'string', + description: + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', }, - tableId: { type: 'string', description: 'Workspace table ID.' }, }, + required: ['path', 'mode'], }, }, }, }, - language: { + sandboxId: { type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default run_function environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', + }, + timeout: { + type: 'number', + description: + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + default: 10, }, title: { type: 'string', description: - 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, required: ['code'], }, requiredPermission: 'write', requiresApproval: true, - capabilities: ['file_input', 'directory_input', 'table_input'], -} - -export const RunFromBlock: ToolCatalogEntry = { - id: 'run_from_block', - name: 'run_from_block', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', - description: - 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', - }, - startBlockId: { type: 'string', description: 'The block ID to start execution from.' }, - useDeployedState: { - type: 'boolean', - description: - 'When true, runs the deployed version instead of the live draft. Default: false (draft).', - }, - workflowId: { - type: 'string', - description: - 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', - }, - workflow_input: { - type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', - }, - }, - required: ['workflowId', 'startBlockId'], - }, - clientExecutable: true, + capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } export const RunWorkflow: ToolCatalogEntry = { @@ -4517,26 +4570,31 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { requiresApproval: true, } -export const ScrapePage: ToolCatalogEntry = { - id: 'scrape_page', - name: 'scrape_page', - route: 'go', - mode: 'sync', +export const SaveUpload: ToolCatalogEntry = { + id: 'save_upload', + name: 'save_upload', + route: 'sim', + mode: 'async', parameters: { type: 'object', properties: { - include_links: { - type: 'boolean', - description: 'Extract all links from the page (default false)', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { type: 'string' }, }, - url: { type: 'string', description: 'The URL to scrape (must include https://)' }, - wait_for: { + operation: { type: 'string', - description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + description: + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', + enum: ['save', 'import', 'extract'], + default: 'save', }, }, - required: ['url'], + required: ['fileNames'], }, + requiredPermission: 'write', } export const Search: ToolCatalogEntry = { @@ -4559,26 +4617,6 @@ export const Search: ToolCatalogEntry = { internal: true, } -export const SearchDocumentation: ToolCatalogEntry = { - id: 'search_documentation', - name: 'search_documentation', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'The search query' }, - topK: { - type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, - }, - }, - required: ['query'], - }, -} - export const SearchIntegrationTools: ToolCatalogEntry = { id: 'search_integration_tools', name: 'search_integration_tools', @@ -4680,64 +4718,23 @@ export const SearchLibraryDocs: ToolCatalogEntry = { }, } -export const SearchOnline: ToolCatalogEntry = { - id: 'search_online', - name: 'search_online', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - category: { - type: 'string', - description: 'Filter by category', - enum: [ - 'news', - 'tweet', - 'github', - 'company', - 'research paper', - 'linkedin profile', - 'pdf', - 'personal site', - ], - }, - include_text: { type: 'boolean', description: 'Include page text content (default true)' }, - num_results: { type: 'number', description: 'Number of results (default 10, max 25)' }, - query: { type: 'string', description: 'Natural language search query' }, - toolTitle: { - type: 'string', - description: - "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", - }, - }, - required: ['query', 'toolTitle'], - }, -} - -export const SearchPatterns: ToolCatalogEntry = { - id: 'search_patterns', - name: 'search_patterns', - route: 'go', - mode: 'sync', +export const SearchSimDocs: ToolCatalogEntry = { + id: 'search_sim_docs', + name: 'search_sim_docs', + route: 'sim', + mode: 'async', parameters: { type: 'object', properties: { - limit: { - type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', - }, - queries: { - type: 'array', + query: { type: 'string', description: 'The search query' }, + topK: { + type: 'number', description: - 'Up to 3 descriptive strings explaining the workflow pattern(s) you need. Focus on intent and desired outcomes.', - items: { - type: 'string', - description: 'Example: "how to automate wealthbox meeting notes into follow-up tasks"', - }, + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, - required: ['queries'], + required: ['query'], }, } @@ -5409,6 +5406,79 @@ export const TableRows: ToolCatalogEntry = { }, } +export const TableViews: ToolCatalogEntry = { + id: 'table_views', + name: 'table_views', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + }, + hiddenColumns: { + type: 'array', + description: + 'Column names to hide in the UI when this view is active. Display-only — queries through the view still return every column.', + items: { type: 'string' }, + }, + isDefault: { + type: 'boolean', + description: + "Make this view the table's default (at most one per table; setting it clears the previous default).", + }, + name: { + type: 'string', + description: + "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + }, + sort: { + type: 'array', + description: + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', + }, + tableId: { type: 'string', description: 'Table ID (required for every operation)' }, + viewId: { + type: 'string', + description: + 'View ID (required for get_view, update_view, delete_view, set_default_view)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The view operation to perform', + enum: [ + 'list_views', + 'get_view', + 'create_view', + 'update_view', + 'delete_view', + 'set_default_view', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -5548,7 +5618,7 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { version: { type: 'number', description: - 'The numeric deployment version number to update (use get_deployment_log to find it).', + 'The numeric deployment version number to update (use list_deployment_versions to find it).', }, workflowId: { type: 'string', @@ -5910,35 +5980,145 @@ export const UserTable: ToolCatalogEntry = { ], }, }, - required: ['operation', 'args'], + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Operation-specific result payload.' }, + message: { type: 'string', description: 'Human-readable outcome summary.' }, + success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + }, + required: ['success', 'message'], + }, +} + +export const Wait: ToolCatalogEntry = { + id: 'wait', + name: 'wait', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, + }, + required: ['seconds'], + }, +} + +export const WebCrawl: ToolCatalogEntry = { + id: 'web_crawl', + name: 'web_crawl', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + exclude_paths: { + type: 'array', + description: 'Skip URLs matching these patterns', + items: { type: 'string' }, + }, + include_paths: { + type: 'array', + description: 'Only crawl URLs matching these patterns', + items: { type: 'string' }, + }, + limit: { type: 'number', description: 'Maximum pages to crawl (default 10, max 50)' }, + max_depth: { type: 'number', description: 'How deep to follow links (default 2)' }, + url: { type: 'string', description: 'Starting URL to crawl from' }, + }, + required: ['url'], + }, +} + +export const WebFetch: ToolCatalogEntry = { + id: 'web_fetch', + name: 'web_fetch', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + include_highlights: { + type: 'boolean', + description: 'Include key highlights (default false)', + }, + include_summary: { + type: 'boolean', + description: 'Include AI-generated summary (default false)', + }, + include_text: { type: 'boolean', description: 'Include full page text (default true)' }, + urls: { + type: 'array', + description: 'URLs to get content from (max 10)', + items: { type: 'string' }, + }, + }, + required: ['urls'], }, - resultSchema: { +} + +export const WebScrape: ToolCatalogEntry = { + id: 'web_scrape', + name: 'web_scrape', + route: 'go', + mode: 'sync', + parameters: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, - message: { type: 'string', description: 'Human-readable outcome summary.' }, - success: { type: 'boolean', description: 'Whether the operation succeeded.' }, + include_links: { + type: 'boolean', + description: 'Extract all links from the page (default false)', + }, + url: { type: 'string', description: 'The URL to scrape (must include https://)' }, + wait_for: { + type: 'string', + description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + }, }, - required: ['success', 'message'], + required: ['url'], }, } -export const Wait: ToolCatalogEntry = { - id: 'wait', - name: 'wait', +export const WebSearch: ToolCatalogEntry = { + id: 'web_search', + name: 'web_search', route: 'go', mode: 'sync', parameters: { type: 'object', properties: { - reason: { + category: { + type: 'string', + description: 'Filter by category', + enum: [ + 'news', + 'tweet', + 'github', + 'company', + 'research paper', + 'linkedin profile', + 'pdf', + 'personal site', + ], + }, + include_text: { type: 'boolean', description: 'Include page text content (default true)' }, + num_results: { type: 'number', description: 'Number of results (default 10, max 25)' }, + query: { type: 'string', description: 'Natural language search query' }, + toolTitle: { type: 'string', description: - 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, - seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, }, - required: ['seconds'], + required: ['query', 'toolTitle'], }, } @@ -5974,129 +6154,6 @@ export const Workflow: ToolCatalogEntry = { internal: true, } -export const WorkspaceFile: ToolCatalogEntry = { - id: 'workspace_file', - name: 'workspace_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - operation: { - type: 'string', - description: 'The file operation to perform.', - enum: ['append', 'update', 'patch'], - }, - target: { - type: 'object', - description: 'Explicit file target. Use kind=path + path for existing files.', - properties: { - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['path'], - }, - path: { - type: 'string', - description: - 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', - }, - }, - required: ['kind'], - }, - title: { - type: 'string', - description: - 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', - }, - contentType: { - type: 'string', - description: - 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', - enum: [ - 'text/markdown', - 'text/html', - 'text/plain', - 'application/json', - 'text/csv', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/pdf', - ], - }, - edit: { - type: 'object', - description: - 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.', - properties: { - after_anchor: { - type: 'string', - description: - 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', - }, - anchor: { - type: 'string', - description: - 'Anchor line after which new content is inserted. Required for mode=insert_after.', - }, - before_anchor: { - type: 'string', - description: - 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', - }, - end_anchor: { - type: 'string', - description: 'First line to keep after deletion. Required for mode=delete_between.', - }, - mode: { - type: 'string', - description: 'Anchored edit mode when strategy=anchored.', - enum: ['replace_between', 'insert_after', 'delete_between'], - }, - occurrence: { - type: 'number', - description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', - }, - replaceAll: { - type: 'boolean', - description: - 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', - }, - search: { - type: 'string', - description: - 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', - }, - start_anchor: { - type: 'string', - description: 'First line to delete. Required for mode=delete_between.', - }, - strategy: { - type: 'string', - description: 'Patch strategy.', - enum: ['search_replace', 'anchored'], - }, - }, - }, - }, - required: ['operation', 'target', 'title'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { type: 'string', description: 'Human-readable summary of the outcome.' }, - success: { type: 'boolean', description: 'Whether the file operation succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - export const FfmpegOperation = { overlayAudio: 'overlay_audio', mixAudio: 'mix_audio', @@ -6129,47 +6186,6 @@ export const FfmpegOperationValues = [ FfmpegOperation.probe, ] as const -export const KnowledgeBaseOperation = { - create: 'create', - get: 'get', - query: 'query', - addFile: 'add_file', - update: 'update', - deleteDocument: 'delete_document', - updateDocument: 'update_document', - listTags: 'list_tags', - createTag: 'create_tag', - updateTag: 'update_tag', - deleteTag: 'delete_tag', - getTagUsage: 'get_tag_usage', - addConnector: 'add_connector', - updateConnector: 'update_connector', - deleteConnector: 'delete_connector', - syncConnector: 'sync_connector', -} as const - -export type KnowledgeBaseOperation = - (typeof KnowledgeBaseOperation)[keyof typeof KnowledgeBaseOperation] - -export const KnowledgeBaseOperationValues = [ - KnowledgeBaseOperation.create, - KnowledgeBaseOperation.get, - KnowledgeBaseOperation.query, - KnowledgeBaseOperation.addFile, - KnowledgeBaseOperation.update, - KnowledgeBaseOperation.deleteDocument, - KnowledgeBaseOperation.updateDocument, - KnowledgeBaseOperation.listTags, - KnowledgeBaseOperation.createTag, - KnowledgeBaseOperation.updateTag, - KnowledgeBaseOperation.deleteTag, - KnowledgeBaseOperation.getTagUsage, - KnowledgeBaseOperation.addConnector, - KnowledgeBaseOperation.updateConnector, - KnowledgeBaseOperation.deleteConnector, - KnowledgeBaseOperation.syncConnector, -] as const - export const ManageCredentialOperation = { rename: 'rename', delete: 'delete', @@ -6200,21 +6216,62 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageMcpToolOperation = { +export const ManageKnowledgeBaseOperation = { + create: 'create', + get: 'get', + query: 'query', + addFile: 'add_file', + update: 'update', + deleteDocument: 'delete_document', + updateDocument: 'update_document', + listTags: 'list_tags', + createTag: 'create_tag', + updateTag: 'update_tag', + deleteTag: 'delete_tag', + getTagUsage: 'get_tag_usage', + addConnector: 'add_connector', + updateConnector: 'update_connector', + deleteConnector: 'delete_connector', + syncConnector: 'sync_connector', +} as const + +export type ManageKnowledgeBaseOperation = + (typeof ManageKnowledgeBaseOperation)[keyof typeof ManageKnowledgeBaseOperation] + +export const ManageKnowledgeBaseOperationValues = [ + ManageKnowledgeBaseOperation.create, + ManageKnowledgeBaseOperation.get, + ManageKnowledgeBaseOperation.query, + ManageKnowledgeBaseOperation.addFile, + ManageKnowledgeBaseOperation.update, + ManageKnowledgeBaseOperation.deleteDocument, + ManageKnowledgeBaseOperation.updateDocument, + ManageKnowledgeBaseOperation.listTags, + ManageKnowledgeBaseOperation.createTag, + ManageKnowledgeBaseOperation.updateTag, + ManageKnowledgeBaseOperation.deleteTag, + ManageKnowledgeBaseOperation.getTagUsage, + ManageKnowledgeBaseOperation.addConnector, + ManageKnowledgeBaseOperation.updateConnector, + ManageKnowledgeBaseOperation.deleteConnector, + ManageKnowledgeBaseOperation.syncConnector, +] as const + +export const ManageMcpConnectionOperation = { add: 'add', edit: 'edit', delete: 'delete', list: 'list', } as const -export type ManageMcpToolOperation = - (typeof ManageMcpToolOperation)[keyof typeof ManageMcpToolOperation] +export type ManageMcpConnectionOperation = + (typeof ManageMcpConnectionOperation)[keyof typeof ManageMcpConnectionOperation] -export const ManageMcpToolOperationValues = [ - ManageMcpToolOperation.add, - ManageMcpToolOperation.edit, - ManageMcpToolOperation.delete, - ManageMcpToolOperation.list, +export const ManageMcpConnectionOperationValues = [ + ManageMcpConnectionOperation.add, + ManageMcpConnectionOperation.edit, + ManageMcpConnectionOperation.delete, + ManageMcpConnectionOperation.list, ] as const export const ManageSandboxOperation = { @@ -6250,19 +6307,19 @@ export const ManageSkillOperationValues = [ ManageSkillOperation.list, ] as const -export const MaterializeFileOperation = { - save: 'save', - import: 'import', - extract: 'extract', +export const PrepareFileEditOperation = { + append: 'append', + update: 'update', + patch: 'patch', } as const -export type MaterializeFileOperation = - (typeof MaterializeFileOperation)[keyof typeof MaterializeFileOperation] +export type PrepareFileEditOperation = + (typeof PrepareFileEditOperation)[keyof typeof PrepareFileEditOperation] -export const MaterializeFileOperationValues = [ - MaterializeFileOperation.save, - MaterializeFileOperation.import, - MaterializeFileOperation.extract, +export const PrepareFileEditOperationValues = [ + PrepareFileEditOperation.append, + PrepareFileEditOperation.update, + PrepareFileEditOperation.patch, ] as const export const QueryUserTableOperation = { @@ -6282,6 +6339,20 @@ export const QueryUserTableOperationValues = [ QueryUserTableOperation.queryRows, ] as const +export const SaveUploadOperation = { + save: 'save', + import: 'import', + extract: 'extract', +} as const + +export type SaveUploadOperation = (typeof SaveUploadOperation)[keyof typeof SaveUploadOperation] + +export const SaveUploadOperationValues = [ + SaveUploadOperation.save, + SaveUploadOperation.import, + SaveUploadOperation.extract, +] as const + export const SearchKnowledgeBaseOperation = { get: 'get', query: 'query', @@ -6392,6 +6463,26 @@ export const TableRowsOperationValues = [ TableRowsOperation.deleteRowsByFilter, ] as const +export const TableViewsOperation = { + listViews: 'list_views', + getView: 'get_view', + createView: 'create_view', + updateView: 'update_view', + deleteView: 'delete_view', + setDefaultView: 'set_default_view', +} as const + +export type TableViewsOperation = (typeof TableViewsOperation)[keyof typeof TableViewsOperation] + +export const TableViewsOperationValues = [ + TableViewsOperation.listViews, + TableViewsOperation.getView, + TableViewsOperation.createView, + TableViewsOperation.updateView, + TableViewsOperation.deleteView, + TableViewsOperation.setDefaultView, +] as const + export const TerminalOperation = { run: 'run', read: 'read', @@ -6490,23 +6581,8 @@ export const UserTableOperationValues = [ UserTableOperation.addEnrichment, ] as const -export const WorkspaceFileOperation = { - append: 'append', - update: 'update', - patch: 'patch', -} as const - -export type WorkspaceFileOperation = - (typeof WorkspaceFileOperation)[keyof typeof WorkspaceFileOperation] - -export const WorkspaceFileOperationValues = [ - WorkspaceFileOperation.append, - WorkspaceFileOperation.update, - WorkspaceFileOperation.patch, -] as const - export const TOOL_CATALOG: Record = { - [Agent.id]: Agent, + [ApplyFileEdit.id]: ApplyFileEdit, [Auth.id]: Auth, [Browser.id]: Browser, [BrowserClick.id]: BrowserClick, @@ -6531,26 +6607,21 @@ export const TOOL_CATALOG: Record = { [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, - [CheckDeploymentStatus.id]: CheckDeploymentStatus, [Cp.id]: Cp, - [CrawlWebsite.id]: CrawlWebsite, - [CreateFile.id]: CreateFile, + [CreateEmptyFile.id]: CreateEmptyFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, - [DeployApi.id]: DeployApi, - [DeployChat.id]: DeployChat, - [DeployCustomBlock.id]: DeployCustomBlock, - [DeployMcp.id]: DeployMcp, + [DeployAsApi.id]: DeployAsApi, + [DeployAsChat.id]: DeployAsChat, + [DeployAsMcp.id]: DeployAsMcp, [DiffWorkflows.id]: DiffWorkflows, - [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, - [EditContent.id]: EditContent, + [DownloadFile.id]: DownloadFile, [EditWorkflow.id]: EditWorkflow, - [EnrichmentRun.id]: EnrichmentRun, + [Extensions.id]: Extensions, [Ffmpeg.id]: Ffmpeg, [File.id]: File, - [FunctionExecute.id]: FunctionExecute, [GenerateApiKey.id]: GenerateApiKey, [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, @@ -6558,15 +6629,14 @@ export const TOOL_CATALOG: Record = { [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, - [GetDeploymentLog.id]: GetDeploymentLog, - [GetPageContents.id]: GetPageContents, - [GetPlatformActions.id]: GetPlatformActions, + [GetDeploymentStatus.id]: GetDeploymentStatus, + [GetUiReference.id]: GetUiReference, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, [Knowledge.id]: Knowledge, - [KnowledgeBase.id]: KnowledgeBase, + [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -6575,17 +6645,19 @@ export const TOOL_CATALOG: Record = { [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageMcpTool.id]: ManageMcpTool, + [ManageKnowledgeBase.id]: ManageKnowledgeBase, + [ManageMcpConnection.id]: ManageMcpConnection, [ManageSandbox.id]: ManageSandbox, [ManageSkill.id]: ManageSkill, - [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, [Mkdir.id]: Mkdir, [Mv.id]: Mv, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [PrepareFileEdit.id]: PrepareFileEdit, [PromoteToLive.id]: PromoteToLive, + [PublishCustomBlock.id]: PublishCustomBlock, [QueryLogs.id]: QueryLogs, [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, @@ -6596,17 +6668,17 @@ export const TOOL_CATALOG: Record = { [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, + [RunEnrichment.id]: RunEnrichment, [RunFromBlock.id]: RunFromBlock, + [RunFunction.id]: RunFunction, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, - [ScrapePage.id]: ScrapePage, + [SaveUpload.id]: SaveUpload, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, - [SearchOnline.id]: SearchOnline, - [SearchPatterns.id]: SearchPatterns, + [SearchSimDocs.id]: SearchSimDocs, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, @@ -6617,11 +6689,15 @@ export const TOOL_CATALOG: Record = { [TableEnrichments.id]: TableEnrichments, [TableManage.id]: TableManage, [TableRows.id]: TableRows, + [TableViews.id]: TableViews, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, [Wait.id]: Wait, + [WebCrawl.id]: WebCrawl, + [WebFetch.id]: WebFetch, + [WebScrape.id]: WebScrape, + [WebSearch.id]: WebSearch, [Workflow.id]: Workflow, - [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a3fd31e1c85..50d9fe521a5 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -10,18 +10,37 @@ export interface ToolRuntimeSchemaEntry { } export const TOOL_RUNTIME_SCHEMAS: Record = { - agent: { + apply_file_edit: { parameters: { + type: 'object', properties: { - request: { - description: 'What tool/skill/MCP action is needed.', + content: { type: 'string', + description: + 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', }, }, - required: ['request'], + required: ['content'], + }, + resultSchema: { type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { + type: 'string', + description: 'Human-readable summary of the outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the content was applied successfully.', + }, + }, + required: ['success', 'message'], }, - resultSchema: undefined, }, auth: { parameters: { @@ -1106,18 +1125,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - check_deployment_status: { - parameters: { - type: 'object', - properties: { - workflowId: { - type: 'string', - description: 'Workflow ID to check (defaults to current workflow)', - }, - }, - }, - resultSchema: undefined, - }, cp: { parameters: { type: 'object', @@ -1145,42 +1152,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - crawl_website: { - parameters: { - type: 'object', - properties: { - exclude_paths: { - type: 'array', - description: 'Skip URLs matching these patterns', - items: { - type: 'string', - }, - }, - include_paths: { - type: 'array', - description: 'Only crawl URLs matching these patterns', - items: { - type: 'string', - }, - }, - limit: { - type: 'number', - description: 'Maximum pages to crawl (default 10, max 50)', - }, - max_depth: { - type: 'number', - description: 'How deep to follow links (default 2)', - }, - url: { - type: 'string', - description: 'Starting URL to crawl from', - }, - }, - required: ['url'], - }, - resultSchema: undefined, - }, - create_file: { + create_empty_file: { parameters: { type: 'object', properties: { @@ -1328,7 +1300,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - deploy_api: { + deploy_as_api: { parameters: { type: 'object', properties: { @@ -1382,7 +1354,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -1412,7 +1384,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_chat: { + deploy_as_chat: { parameters: { type: 'object', properties: { @@ -1526,7 +1498,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_chat this is always "chat".', + 'Deployment surface this result describes. For deploy_as_chat this is always "chat".', }, examples: { type: 'object', @@ -1547,7 +1519,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, success: { type: 'boolean', - description: 'Whether the deploy_chat action completed successfully.', + description: 'Whether the deploy_as_chat action completed successfully.', }, version: { type: 'number', @@ -1571,134 +1543,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - deploy_custom_block: { - parameters: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', - enum: ['deploy', 'undeploy'], - default: 'deploy', - }, - description: { - type: 'string', - description: 'Short description shown in the block picker, max 280 characters', - }, - exposedOutputs: { - type: 'array', - description: - "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", - items: { - type: 'object', - properties: { - blockId: { - type: 'string', - description: 'Block UUID inside the workflow', - }, - name: { - type: 'string', - description: 'Friendly output name shown on the block', - }, - path: { - type: 'string', - description: - "Dot-path into that block's output (from get_block_outputs relativeOutputs)", - }, - }, - required: ['blockId', 'path', 'name'], - }, - }, - iconUrl: { - type: 'string', - description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', - }, - inputs: { - type: 'array', - description: - "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", - items: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'Stable id of the input trigger field', - }, - placeholder: { - type: 'string', - description: "Placeholder text shown in the block's input field", - }, - }, - required: ['id'], - }, - }, - name: { - type: 'string', - description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', - }, - workflowId: { - type: 'string', - description: 'Workflow ID (defaults to active workflow)', - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - action: { - type: 'string', - description: 'Action performed by the tool, such as "deploy" or "undeploy".', - }, - blockId: { - type: 'string', - description: 'Custom block record ID.', - }, - blockType: { - type: 'string', - description: 'Stable block type slug (custom_block_*) used in workflow state.', - }, - deploymentConfig: { - type: 'object', - description: - "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", - }, - deploymentStatus: { - type: 'object', - description: - 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', - }, - deploymentType: { - type: 'string', - description: - 'Deployment surface this result describes. For deploy_custom_block this is always "custom_block".', - }, - isDeployed: { - type: 'boolean', - description: 'Whether the custom block is published after this tool call.', - }, - name: { - type: 'string', - description: 'Display name of the custom block.', - }, - removed: { - type: 'boolean', - description: 'Whether the custom block was unpublished during an undeploy action.', - }, - updated: { - type: 'boolean', - description: 'Whether an existing custom block was updated instead of created.', - }, - workflowId: { - type: 'string', - description: 'Workflow ID the custom block is bound to.', - }, - }, - required: ['deploymentType', 'deploymentStatus'], - }, - }, - deploy_mcp: { + deploy_as_mcp: { parameters: { type: 'object', properties: { @@ -1773,7 +1618,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_mcp this is always "mcp".', + 'Deployment surface this result describes. For deploy_as_mcp this is always "mcp".', }, examples: { type: 'object', @@ -1844,7 +1689,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - download_to_workspace_file: { + download_file: { parameters: { type: 'object', properties: { @@ -1893,38 +1738,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - edit_content: { - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: - 'The text content to write. For append: text to append. For update: full replacement text. For patch with search_replace: the replacement text. For patch with anchored: the insert/replacement text.', - }, - }, - required: ['content'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: 'object', - description: - 'Optional operation metadata such as file id, file name, size, and content type.', - }, - message: { - type: 'string', - description: 'Human-readable summary of the outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the content was applied successfully.', - }, - }, - required: ['success', 'message'], - }, - }, edit_workflow: { parameters: { type: 'object', @@ -1964,52 +1777,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - enrichment_run: { + extensions: { parameters: { - type: 'object', properties: { - enrichmentId: { + request: { + description: 'What tool/skill/MCP action is needed.', type: 'string', - description: - "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", - enum: [ - 'work-email', - 'phone-number', - 'company-domain', - 'company-info', - 'email-verification', - ], - }, - inputs: { - type: 'object', - description: - 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', }, }, - required: ['enrichmentId', 'inputs'], + required: ['request'], + type: 'object', }, - resultSchema: { - type: 'object', - properties: { - matched: { - type: 'boolean', - description: 'True when a provider returned a non-empty result.', - }, - provider: { - type: ['string', 'null'], - description: - 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', - }, - result: { - type: 'object', - description: 'Mapped output values from the winning provider (empty object on no match).', - }, - }, - required: ['matched', 'result'], - }, - }, - ffmpeg: { - parameters: { + resultSchema: undefined, + }, + ffmpeg: { + parameters: { type: 'object', properties: { aspectRatio: { @@ -2203,156 +1985,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - function_execute: { - parameters: { - type: 'object', - properties: { - code: { - type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', - }, - inputs: { - type: 'object', - description: - 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', - properties: { - directories: { - type: 'array', - description: - 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', - }, - }, - required: ['path'], - }, - }, - files: { - type: 'array', - description: 'Workspace files to mount into the sandbox.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', - }, - sandboxPath: { - type: 'string', - description: - 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', - }, - }, - required: ['path'], - }, - }, - tables: { - type: 'array', - description: 'Workspace tables to mount as CSV files.', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Canonical VFS table path when available.', - }, - sandboxPath: { - type: 'string', - description: 'Optional full sandbox path for the mounted CSV.', - }, - tableId: { - type: 'string', - description: 'Workspace table ID.', - }, - }, - }, - }, - }, - }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], - }, - outputTable: { - type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', - }, - outputs: { - type: 'object', - description: - 'Workspace files to create or overwrite from returned code results or sandbox-created files.', - properties: { - files: { - type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', - items: { - type: 'object', - properties: { - format: { - type: 'string', - description: 'Optional serialization format for returned values.', - enum: ['json', 'csv', 'txt', 'md', 'html'], - }, - mimeType: { - type: 'string', - description: 'Optional MIME type override when inference is not enough.', - }, - mode: { - type: 'string', - description: 'Create a new file or overwrite an existing file at path.', - enum: ['create', 'overwrite'], - }, - path: { - type: 'string', - description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', - }, - sandboxPath: { - type: 'string', - description: - 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', - }, - }, - required: ['path', 'mode'], - }, - }, - }, - }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, - timeout: { - type: 'number', - description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', - default: 10, - }, - title: { - type: 'string', - description: - 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', - }, - }, - required: ['code'], - }, - resultSchema: undefined, - }, generate_api_key: { parameters: { type: 'object', @@ -2877,48 +2509,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_log: { + get_deployment_status: { parameters: { type: 'object', properties: { workflowId: { type: 'string', - description: - 'Optional workflow ID. If not provided, uses the current workflow in context.', - }, - }, - }, - resultSchema: undefined, - }, - get_page_contents: { - parameters: { - type: 'object', - properties: { - include_highlights: { - type: 'boolean', - description: 'Include key highlights (default false)', - }, - include_summary: { - type: 'boolean', - description: 'Include AI-generated summary (default false)', - }, - include_text: { - type: 'boolean', - description: 'Include full page text (default true)', - }, - urls: { - type: 'array', - description: 'URLs to get content from (max 10)', - items: { - type: 'string', - }, + description: 'Workflow ID to check (defaults to current workflow)', }, }, - required: ['urls'], }, resultSchema: undefined, }, - get_platform_actions: { + get_ui_reference: { parameters: { type: 'object', properties: {}, @@ -3037,218 +2640,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - knowledge_base: { + list_deployment_versions: { parameters: { type: 'object', properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - apiKey: { - type: 'string', - description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', - }, - chunkingConfig: { - type: 'object', - description: "Chunking configuration (optional for 'create')", - properties: { - maxSize: { - type: 'number', - description: 'Maximum chunk size (100-4000, default: 1024)', - default: 1024, - }, - minSize: { - type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', - default: 1, - }, - overlap: { - type: 'number', - description: 'Overlap between chunks (0-500, default: 200)', - default: 200, - }, - }, - }, - connectorId: { - type: 'string', - description: - 'Connector ID (required for update_connector, delete_connector, sync_connector)', - }, - connectorStatus: { - type: 'string', - description: 'Connector status (optional for update_connector)', - enum: ['active', 'paused'], - }, - connectorType: { - type: 'string', - description: - "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", - }, - credentialId: { - type: 'string', - description: - 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', - }, - description: { - type: 'string', - description: "Description of the knowledge base (optional for 'create')", - }, - disabledTagIds: { - type: 'array', - description: - 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', - }, - documentId: { - type: 'string', - description: 'Document ID (required for update_document)', - }, - documentIds: { - type: 'array', - description: 'Document IDs (for batch delete_document)', - items: { - type: 'string', - }, - }, - enabled: { - type: 'boolean', - description: 'Enable/disable a document (optional for update_document)', - }, - filePaths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', - items: { - type: 'string', - }, - }, - filename: { - type: 'string', - description: 'New filename for a document (optional for update_document)', - }, - knowledgeBaseId: { - type: 'string', - description: - 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', - }, - knowledgeBaseIds: { - type: 'array', - description: 'Knowledge base IDs (for batch delete)', - items: { - type: 'string', - }, - }, - name: { - type: 'string', - description: "Name of the knowledge base (required for 'create')", - }, - query: { - type: 'string', - description: "Search query text (required for 'query')", - }, - sourceConfig: { - type: 'object', - description: - 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', - }, - syncIntervalMinutes: { - type: 'number', - description: - 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', - default: 1440, - }, - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID (required for update_tag, delete_tag)', - }, - tagDisplayName: { - type: 'string', - description: - 'Display name for the tag (required for create_tag, optional for update_tag)', - }, - tagFieldType: { - type: 'string', - description: - 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', - enum: ['text', 'number', 'date', 'boolean'], - }, - tagValues: { - type: 'array', - description: - 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', - items: { - type: 'object', - properties: { - tagDefinitionId: { - type: 'string', - description: 'Tag definition ID returned by list_tags.', - }, - value: { - type: ['string', 'number', 'boolean', 'null'], - description: - "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", - }, - }, - required: ['tagDefinitionId', 'value'], - }, - }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - workspaceId: { - type: 'string', - description: - "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", - }, - }, - }, - operation: { + workflowId: { type: 'string', - description: 'The operation to perform', - enum: [ - 'create', - 'get', - 'query', - 'add_file', - 'update', - 'delete_document', - 'update_document', - 'list_tags', - 'create_tag', - 'update_tag', - 'delete_tag', - 'get_tag_usage', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - ], - }, - }, - required: ['operation'], - }, - resultSchema: { - type: 'object', - properties: { - data: { - type: ['object', 'array'], description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', - }, - message: { - type: 'string', - description: 'Human-readable outcome summary.', - }, - success: { - type: 'boolean', - description: 'Whether the operation succeeded.', + 'Optional workflow ID. If not provided, uses the current workflow in context.', }, }, - required: ['success', 'message'], }, + resultSchema: undefined, }, list_integration_tools: { parameters: { @@ -3426,24 +2829,237 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['type', 'function'], }, - toolId: { + toolId: { + type: 'string', + description: + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + }, + toolIds: { + type: 'array', + description: 'Array of custom tool IDs (for batch delete)', + items: { + type: 'string', + }, + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, + manage_knowledge_base: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + apiKey: { + type: 'string', + description: + 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + }, + chunkingConfig: { + type: 'object', + description: "Chunking configuration (optional for 'create')", + properties: { + maxSize: { + type: 'number', + description: 'Maximum chunk size (100-4000, default: 1024)', + default: 1024, + }, + minSize: { + type: 'number', + description: 'Minimum chunk size (1-2000, default: 1)', + default: 1, + }, + overlap: { + type: 'number', + description: 'Overlap between chunks (0-500, default: 200)', + default: 200, + }, + }, + }, + connectorId: { + type: 'string', + description: + 'Connector ID (required for update_connector, delete_connector, sync_connector)', + }, + connectorStatus: { + type: 'string', + description: 'Connector status (optional for update_connector)', + enum: ['active', 'paused'], + }, + connectorType: { + type: 'string', + description: + "Connector type from registry, e.g. 'confluence', 'google_drive', 'notion' (required for add_connector). Read knowledgebases/connectors/{type}.json for the config schema.", + }, + credentialId: { + type: 'string', + description: + 'OAuth credential ID from environment/credentials.json (required for OAuth connectors)', + }, + description: { + type: 'string', + description: "Description of the knowledge base (optional for 'create')", + }, + disabledTagIds: { + type: 'array', + description: + 'Tag definition IDs to opt out of (optional for add_connector). See tagDefinitions in the connector schema.', + }, + documentId: { + type: 'string', + description: 'Document ID (required for update_document)', + }, + documentIds: { + type: 'array', + description: 'Document IDs (for batch delete_document)', + items: { + type: 'string', + }, + }, + enabled: { + type: 'boolean', + description: 'Enable/disable a document (optional for update_document)', + }, + filePaths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', + items: { + type: 'string', + }, + }, + filename: { + type: 'string', + description: 'New filename for a document (optional for update_document)', + }, + knowledgeBaseId: { + type: 'string', + description: + 'Knowledge base ID (required for get, query, add_file, list_tags, create_tag, get_tag_usage)', + }, + knowledgeBaseIds: { + type: 'array', + description: 'Knowledge base IDs (for batch delete)', + items: { + type: 'string', + }, + }, + name: { + type: 'string', + description: "Name of the knowledge base (required for 'create')", + }, + query: { + type: 'string', + description: "Search query text (required for 'query')", + }, + sourceConfig: { + type: 'object', + description: + 'Connector-specific configuration matching the configFields in knowledgebases/connectors/{type}.json', + }, + syncIntervalMinutes: { + type: 'number', + description: + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + default: 1440, + }, + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID (required for update_tag, delete_tag)', + }, + tagDisplayName: { + type: 'string', + description: + 'Display name for the tag (required for create_tag, optional for update_tag)', + }, + tagFieldType: { + type: 'string', + description: + 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', + enum: ['text', 'number', 'date', 'boolean'], + }, + tagValues: { + type: 'array', + description: + 'Typed tag values to set on this document (optional for update_document). Resolve tagDefinitionId with list_tags first. Use null to clear a value.', + items: { + type: 'object', + properties: { + tagDefinitionId: { + type: 'string', + description: 'Tag definition ID returned by list_tags.', + }, + value: { + type: ['string', 'number', 'boolean', 'null'], + description: + "Value matching the tag definition's field type: string for text, number for number, YYYY-MM-DD string for date, boolean for boolean, or null to clear.", + }, + }, + required: ['tagDefinitionId', 'value'], + }, + }, + topK: { + type: 'number', + description: 'Number of results to return (1-50, default: 5)', + default: 5, + }, + workspaceId: { + type: 'string', + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", + }, + }, + }, + operation: { + type: 'string', + description: 'The operation to perform', + enum: [ + 'create', + 'get', + 'query', + 'add_file', + 'update', + 'delete_document', + 'update_document', + 'list_tags', + 'create_tag', + 'update_tag', + 'delete_tag', + 'get_tag_usage', + 'add_connector', + 'update_connector', + 'delete_connector', + 'sync_connector', + ], + }, + }, + required: ['operation'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + }, + message: { type: 'string', - description: - "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", + description: 'Human-readable outcome summary.', }, - toolIds: { - type: 'array', - description: 'Array of custom tool IDs (for batch delete)', - items: { - type: 'string', - }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', }, }, - required: ['operation'], + required: ['success', 'message'], }, - resultSchema: undefined, }, - manage_mcp_tool: { + manage_mcp_connection: { parameters: { type: 'object', properties: { @@ -3582,30 +3198,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - materialize_file: { - parameters: { - type: 'object', - properties: { - fileNames: { - type: 'array', - description: - 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', - items: { - type: 'string', - }, - }, - operation: { - type: 'string', - description: - 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', - enum: ['save', 'import', 'extract'], - default: 'save', - }, - }, - required: ['fileNames'], - }, - resultSchema: undefined, - }, media: { parameters: { properties: { @@ -3708,51 +3300,306 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { resources: { type: 'array', description: - 'Array of resources to open. Each item must have type and either id or, for files, path.', + 'Array of resources to open. Each item must have type and either id or, for files, path.', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Canonical resource ID for non-file resources.', + }, + path: { + type: 'string', + description: + 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', + }, + type: { + type: 'string', + description: 'The resource type.', + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + }, + view: { + type: 'string', + description: + 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + }, + }, + required: ['type'], + }, + }, + }, + required: ['resources'], + }, + resultSchema: undefined, + }, + prepare_file_edit: { + parameters: { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'The file operation to perform.', + enum: ['append', 'update', 'patch'], + }, + target: { + type: 'object', + description: 'Explicit file target. Use kind=path + path for existing files.', + properties: { + kind: { + type: 'string', + description: 'How the file target is identified.', + enum: ['path'], + }, + path: { + type: 'string', + description: + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', + }, + }, + required: ['kind'], + }, + title: { + type: 'string', + description: + 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + }, + contentType: { + type: 'string', + description: + 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + enum: [ + 'text/markdown', + 'text/html', + 'text/plain', + 'application/json', + 'text/csv', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + ], + }, + edit: { + type: 'object', + description: + 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired apply_file_edit tool call.', + properties: { + after_anchor: { + type: 'string', + description: + 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', + }, + anchor: { + type: 'string', + description: + 'Anchor line after which new content is inserted. Required for mode=insert_after.', + }, + before_anchor: { + type: 'string', + description: + 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', + }, + end_anchor: { + type: 'string', + description: 'First line to keep after deletion. Required for mode=delete_between.', + }, + mode: { + type: 'string', + description: 'Anchored edit mode when strategy=anchored.', + enum: ['replace_between', 'insert_after', 'delete_between'], + }, + occurrence: { + type: 'number', + description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', + }, + replaceAll: { + type: 'boolean', + description: + 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', + }, + search: { + type: 'string', + description: + 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', + }, + start_anchor: { + type: 'string', + description: 'First line to delete. Required for mode=delete_between.', + }, + strategy: { + type: 'string', + description: 'Patch strategy.', + enum: ['search_replace', 'anchored'], + }, + }, + }, + }, + required: ['operation', 'target', 'title'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Optional operation metadata such as file id, file name, size, and content type.', + }, + message: { + type: 'string', + description: 'Human-readable summary of the outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the file operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + promote_to_live: { + parameters: { + type: 'object', + properties: { + version: { + type: 'number', + description: + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + resultSchema: undefined, + }, + publish_custom_block: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to publish (deploy) or unpublish (undeploy) the custom block', + enum: ['deploy', 'undeploy'], + default: 'deploy', + }, + description: { + type: 'string', + description: 'Short description shown in the block picker, max 280 characters', + }, + exposedOutputs: { + type: 'array', + description: + "Outputs the block exposes, each mapping a child block output path to a friendly name (use get_block_outputs for valid paths). Omit to expose the terminal block's whole result", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Block UUID inside the workflow', + }, + name: { + type: 'string', + description: 'Friendly output name shown on the block', + }, + path: { + type: 'string', + description: + "Dot-path into that block's output (from get_block_outputs relativeOutputs)", + }, + }, + required: ['blockId', 'path', 'name'], + }, + }, + iconUrl: { + type: 'string', + description: + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', + }, + inputs: { + type: 'array', + description: + "Optional per-input placeholder overrides. Input names and types are derived from the workflow's input trigger and cannot be changed here", items: { type: 'object', properties: { id: { type: 'string', - description: 'Canonical resource ID for non-file resources.', - }, - path: { - type: 'string', - description: - 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', + description: 'Stable id of the input trigger field', }, - type: { + placeholder: { type: 'string', - description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + description: "Placeholder text shown in the block's input field", }, }, - required: ['type'], + required: ['id'], }, }, + name: { + type: 'string', + description: + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (defaults to active workflow)', + }, }, - required: ['resources'], }, - resultSchema: undefined, - }, - promote_to_live: { - parameters: { + resultSchema: { type: 'object', properties: { - version: { - type: 'number', + action: { + type: 'string', + description: 'Action performed by the tool, such as "deploy" or "undeploy".', + }, + blockId: { + type: 'string', + description: 'Custom block record ID.', + }, + blockType: { + type: 'string', + description: 'Stable block type slug (custom_block_*) used in workflow state.', + }, + deploymentConfig: { + type: 'object', description: - 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + "Structured deployment configuration keyed by surface name. Includes the block's type, name, description, icon, derived input fields, and exposed outputs.", }, - workflowId: { + deploymentStatus: { + type: 'object', + description: + 'Structured per-surface deployment status keyed by surface name, including customBlock and the underlying api surface when applicable.', + }, + deploymentType: { type: 'string', description: - 'Optional workflow ID. If not provided, uses the current workflow in context.', + 'Deployment surface this result describes. For publish_custom_block this is always "custom_block".', + }, + isDeployed: { + type: 'boolean', + description: 'Whether the custom block is published after this tool call.', + }, + name: { + type: 'string', + description: 'Display name of the custom block.', + }, + removed: { + type: 'boolean', + description: 'Whether the custom block was unpublished during an undeploy action.', + }, + updated: { + type: 'boolean', + description: 'Whether an existing custom block was updated instead of created.', + }, + workflowId: { + type: 'string', + description: 'Workflow ID the custom block is bound to.', }, }, - required: ['version'], + required: ['deploymentType', 'deploymentStatus'], }, - resultSchema: undefined, }, query_logs: { parameters: { @@ -3901,6 +3748,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Table ID (required for all operations)', }, + view: { + type: 'string', + description: + "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + }, }, }, operation: { @@ -4006,7 +3858,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { deploymentType: { type: 'string', description: - 'Deployment surface this result describes. For deploy_api and redeploy this is always "api".', + 'Deployment surface this result describes. For deploy_as_api and redeploy this is always "api".', }, examples: { type: 'object', @@ -4119,24 +3971,193 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, - required: ['request'], - type: 'object', + required: ['request'], + type: 'object', + }, + resultSchema: undefined, + }, + run_block: { + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'The block ID to run in isolation.', + }, + executionId: { + type: 'string', + description: + 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + }, + useDeployedState: { + type: 'boolean', + description: + 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + }, + workflowId: { + type: 'string', + description: + 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', + }, + workflow_input: { + type: 'object', + description: 'JSON object with key-value mappings where each key is an input field name', + }, + }, + required: ['workflowId', 'blockId'], + }, + resultSchema: undefined, + }, + run_code: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + }, + }, + required: ['code'], + }, + resultSchema: undefined, + }, + run_enrichment: { + parameters: { + type: 'object', + properties: { + enrichmentId: { + type: 'string', + description: + "Which enrichment to run. Discover the full set and each one's inputs/outputs via table_enrichments.list_enrichments.", + enum: [ + 'work-email', + 'phone-number', + 'company-domain', + 'company-info', + 'email-verification', + ], + }, + inputs: { + type: 'object', + description: + 'Map of the enrichment\'s input id → value, e.g. { "fullName": "Jane Doe", "companyDomain": "acme.com" }. Provide a value for every required input.', + }, + }, + required: ['enrichmentId', 'inputs'], + }, + resultSchema: { + type: 'object', + properties: { + matched: { + type: 'boolean', + description: 'True when a provider returned a non-empty result.', + }, + provider: { + type: ['string', 'null'], + description: + 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', + }, + result: { + type: 'object', + description: 'Mapped output values from the winning provider (empty object on no match).', + }, + }, + required: ['matched', 'result'], }, - resultSchema: undefined, }, - run_block: { + run_from_block: { parameters: { type: 'object', properties: { - blockId: { - type: 'string', - description: 'The block ID to run in isolation.', - }, executionId: { type: 'string', description: 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', }, + startBlockId: { + type: 'string', + description: 'The block ID to start execution from.', + }, useDeployedState: { type: 'boolean', description: @@ -4152,11 +4173,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'JSON object with key-value mappings where each key is an input field name', }, }, - required: ['workflowId', 'blockId'], + required: ['workflowId', 'startBlockId'], }, resultSchema: undefined, }, - run_code: { + run_function: { parameters: { type: 'object', properties: { @@ -4239,45 +4260,70 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Execution language.', enum: ['javascript', 'python', 'shell'], }, - title: { + outputTable: { type: 'string', description: - 'Short user-visible label for this execution, e.g. "Sum June invoices" or "Verify email formats".', + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, - }, - required: ['code'], - }, - resultSchema: undefined, - }, - run_from_block: { - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', + outputs: { + type: 'object', description: - 'Optional execution ID to load the snapshot from. Uses latest execution if omitted.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: + 'File outputs. Missing parent folders are created automatically for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - startBlockId: { + sandboxId: { type: 'string', - description: 'The block ID to start execution from.', + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default run_function environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', }, - useDeployedState: { - type: 'boolean', + timeout: { + type: 'number', description: - 'When true, runs the deployed version instead of the live draft. Default: false (draft).', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + default: 10, }, - workflowId: { + title: { type: 'string', description: - 'ID of the workflow to run. Always pass this explicitly — outside a workflow-scoped chat there is no current workflow to fall back to, and the run is rejected without it.', - }, - workflow_input: { - type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', }, }, - required: ['workflowId', 'startBlockId'], + required: ['code'], }, resultSchema: undefined, }, @@ -4368,24 +4414,27 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scrape_page: { + save_upload: { parameters: { type: 'object', properties: { - include_links: { - type: 'boolean', - description: 'Extract all links from the page (default false)', - }, - url: { - type: 'string', - description: 'The URL to scrape (must include https://)', + fileNames: { + type: 'array', + description: + 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', + items: { + type: 'string', + }, }, - wait_for: { + operation: { type: 'string', - description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + description: + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. "extract" decompresses a .zip upload into files//. Defaults to "save".', + enum: ['save', 'import', 'extract'], + default: 'save', }, }, - required: ['url'], + required: ['fileNames'], }, resultSchema: undefined, }, @@ -4403,25 +4452,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query', - }, - topK: { - type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, - }, - }, - required: ['query'], - }, - resultSchema: undefined, - }, search_integration_tools: { parameters: { properties: { @@ -4519,65 +4549,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_online: { + search_sim_docs: { parameters: { type: 'object', properties: { - category: { - type: 'string', - description: 'Filter by category', - enum: [ - 'news', - 'tweet', - 'github', - 'company', - 'research paper', - 'linkedin profile', - 'pdf', - 'personal site', - ], - }, - include_text: { - type: 'boolean', - description: 'Include page text content (default true)', - }, - num_results: { - type: 'number', - description: 'Number of results (default 10, max 25)', - }, query: { type: 'string', - description: 'Natural language search query', - }, - toolTitle: { - type: 'string', - description: - "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", - }, - }, - required: ['query', 'toolTitle'], - }, - resultSchema: undefined, - }, - search_patterns: { - parameters: { - type: 'object', - properties: { - limit: { - type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', + description: 'The search query', }, - queries: { - type: 'array', + topK: { + type: 'number', description: - 'Up to 3 descriptive strings explaining the workflow pattern(s) you need. Focus on intent and desired outcomes.', - items: { - type: 'string', - description: 'Example: "how to automate wealthbox meeting notes into follow-up tasks"', - }, + 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', + default: 10, }, }, - required: ['queries'], + required: ['query'], }, resultSchema: undefined, }, @@ -5258,46 +5245,128 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Row ID (required for update_row, delete_row)', }, - rowIds: { + rowIds: { + type: 'array', + description: 'Array of row IDs to delete (required for batch_delete_rows)', + items: { + type: 'string', + }, + }, + rows: { + type: 'array', + description: 'Array of row data objects (required for batch_insert_rows)', + }, + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + updates: { + type: 'array', + description: + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + }, + values: { + type: 'object', + description: + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + }, + }, + required: ['tableId'], + }, + operation: { + type: 'string', + description: 'The row operation to perform', + enum: [ + 'insert_row', + 'batch_insert_rows', + 'update_row', + 'batch_update_rows', + 'delete_row', + 'batch_delete_rows', + 'update_rows_by_filter', + 'delete_rows_by_filter', + ], + }, + }, + required: ['operation', 'args'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Operation-specific result payload.', + }, + message: { + type: 'string', + description: 'Human-readable outcome summary.', + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + table_views: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Arguments for the operation', + properties: { + filter: { + type: 'object', + description: + 'Saved row predicate, same grammar as query_rows filters: {"all":[...]} / {"any":[...]} of {field, op, value} leaves with exact column NAMES. Omit or null for an unfiltered view.', + }, + hiddenColumns: { type: 'array', - description: 'Array of row IDs to delete (required for batch_delete_rows)', + description: + 'Column names to hide in the UI when this view is active. Display-only — queries through the view still return every column.', items: { type: 'string', }, }, - rows: { - type: 'array', - description: 'Array of row data objects (required for batch_insert_rows)', + isDefault: { + type: 'boolean', + description: + "Make this view the table's default (at most one per table; setting it clears the previous default).", }, - tableId: { + name: { type: 'string', - description: 'Table ID (required for every operation)', + description: + "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", }, - updates: { + sort: { type: 'array', description: - 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', + 'Saved ordered sort spec, e.g. [{"field":"due","direction":"asc"}], column NAMES. Omit or null for default ordering.', }, - values: { - type: 'object', + tableId: { + type: 'string', + description: 'Table ID (required for every operation)', + }, + viewId: { + type: 'string', description: - 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', + 'View ID (required for get_view, update_view, delete_view, set_default_view)', }, }, required: ['tableId'], }, operation: { type: 'string', - description: 'The row operation to perform', + description: 'The view operation to perform', enum: [ - 'insert_row', - 'batch_insert_rows', - 'update_row', - 'batch_update_rows', - 'delete_row', - 'batch_delete_rows', - 'update_rows_by_filter', - 'delete_rows_by_filter', + 'list_views', + 'get_view', + 'create_view', + 'update_view', + 'delete_view', + 'set_default_view', ], }, }, @@ -5453,7 +5522,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { version: { type: 'number', description: - 'The numeric deployment version number to update (use get_deployment_log to find it).', + 'The numeric deployment version number to update (use list_deployment_versions to find it).', }, workflowId: { type: 'string', @@ -5883,153 +5952,154 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - workflow: { + web_crawl: { parameters: { + type: 'object', properties: { - prompt: { - description: - 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', - type: 'string', + exclude_paths: { + type: 'array', + description: 'Skip URLs matching these patterns', + items: { + type: 'string', + }, }, - sessionId: { - description: - 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', - type: 'string', + include_paths: { + type: 'array', + description: 'Only crawl URLs matching these patterns', + items: { + type: 'string', + }, }, - title: { - description: - "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", - maxLength: 120, - minLength: 1, + limit: { + type: 'number', + description: 'Maximum pages to crawl (default 10, max 50)', + }, + max_depth: { + type: 'number', + description: 'How deep to follow links (default 2)', + }, + url: { type: 'string', + description: 'Starting URL to crawl from', }, }, - required: ['title'], - type: 'object', + required: ['url'], }, resultSchema: undefined, }, - workspace_file: { + web_fetch: { parameters: { type: 'object', properties: { - operation: { - type: 'string', - description: 'The file operation to perform.', - enum: ['append', 'update', 'patch'], + include_highlights: { + type: 'boolean', + description: 'Include key highlights (default false)', }, - target: { - type: 'object', - description: 'Explicit file target. Use kind=path + path for existing files.', - properties: { - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['path'], - }, - path: { - type: 'string', - description: - 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', - }, + include_summary: { + type: 'boolean', + description: 'Include AI-generated summary (default false)', + }, + include_text: { + type: 'boolean', + description: 'Include full page text (default true)', + }, + urls: { + type: 'array', + description: 'URLs to get content from (max 10)', + items: { + type: 'string', }, - required: ['kind'], }, - title: { + }, + required: ['urls'], + }, + resultSchema: undefined, + }, + web_scrape: { + parameters: { + type: 'object', + properties: { + include_links: { + type: 'boolean', + description: 'Extract all links from the page (default false)', + }, + url: { type: 'string', - description: - 'Required short UI label for this content unit, e.g. "Chapter 1", "Slide 3", or "Fix footer spacing".', + description: 'The URL to scrape (must include https://)', }, - contentType: { + wait_for: { type: 'string', - description: - 'Optional MIME type override. Usually omit and let the system infer from the target file extension.', + description: 'CSS selector to wait for before scraping (for JS-heavy pages)', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + web_search: { + parameters: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Filter by category', enum: [ - 'text/markdown', - 'text/html', - 'text/plain', - 'application/json', - 'text/csv', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/pdf', + 'news', + 'tweet', + 'github', + 'company', + 'research paper', + 'linkedin profile', + 'pdf', + 'personal site', ], }, - edit: { - type: 'object', + include_text: { + type: 'boolean', + description: 'Include page text content (default true)', + }, + num_results: { + type: 'number', + description: 'Number of results (default 10, max 25)', + }, + query: { + type: 'string', + description: 'Natural language search query', + }, + toolTitle: { + type: 'string', description: - 'Patch metadata. Use strategy=search_replace for exact text replacement, or strategy=anchored for line-based inserts/replacements/deletions. The actual replacement/insert content is provided via the paired edit_content tool call.', - properties: { - after_anchor: { - type: 'string', - description: - 'Boundary line kept after inserted replacement content. Required for mode=replace_between.', - }, - anchor: { - type: 'string', - description: - 'Anchor line after which new content is inserted. Required for mode=insert_after.', - }, - before_anchor: { - type: 'string', - description: - 'Boundary line kept before inserted replacement content. Required for mode=replace_between.', - }, - end_anchor: { - type: 'string', - description: 'First line to keep after deletion. Required for mode=delete_between.', - }, - mode: { - type: 'string', - description: 'Anchored edit mode when strategy=anchored.', - enum: ['replace_between', 'insert_after', 'delete_between'], - }, - occurrence: { - type: 'number', - description: '1-based occurrence for repeated anchor lines. Optional; defaults to 1.', - }, - replaceAll: { - type: 'boolean', - description: - 'When true and strategy=search_replace, replace every match instead of requiring a unique single match.', - }, - search: { - type: 'string', - description: - 'Exact text to find when strategy=search_replace. Must match exactly once unless replaceAll=true.', - }, - start_anchor: { - type: 'string', - description: 'First line to delete. Required for mode=delete_between.', - }, - strategy: { - type: 'string', - description: 'Patch strategy.', - enum: ['search_replace', 'anchored'], - }, - }, + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, }, - required: ['operation', 'target', 'title'], + required: ['query', 'toolTitle'], }, - resultSchema: { - type: 'object', + resultSchema: undefined, + }, + workflow: { + parameters: { properties: { - data: { - type: 'object', + prompt: { description: - 'Optional operation metadata such as file id, file name, size, and content type.', + 'Optional brief instruction (one short sentence) to add scoping that the conversation does not convey. Usually omit it: a new session inherits the current conversation, and a resumed session receives the parent messages it has not yet seen. Do NOT restate or rewrite conversation content.', + type: 'string', }, - message: { + sessionId: { + description: + 'Reusable session ID returned by an earlier workflow call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — never re-pass a sessionId already used this turn; the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', type: 'string', - description: 'Human-readable summary of the outcome.', }, - success: { - type: 'boolean', - description: 'Whether the file operation succeeded.', + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. It is stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator and is not shown to or used as an instruction for the workflow agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + minLength: 1, + type: 'string', }, }, - required: ['success', 'message'], + required: ['title'], + type: 'object', }, + resultSchema: undefined, }, } diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index 1947b635512..b8659cb2d6c 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -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, diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts index 27851eabba6..3e14e0bd0ee 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts @@ -54,21 +54,21 @@ function toolEvent(payload: Record): 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', diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index b79254a211e..d347b01fd6f 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -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, diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 5921c6e89b7..25f550a3a03 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -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' } }, }) diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 32562b8293b..82762e5d46a 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -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) ) diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index c7e340b0133..54bf70d7800 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -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 diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/copilot/request/session/contract.test.ts index 06661e275da..86dcbc17fb4 100644 --- a/apps/sim/lib/copilot/request/session/contract.test.ts +++ b/apps/sim/lib/copilot/request/session/contract.test.ts @@ -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, diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/copilot/request/session/contract.ts index dde683b966c..e7b6eb99206 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/copilot/request/session/contract.ts @@ -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 } diff --git a/apps/sim/lib/copilot/request/session/event.test.ts b/apps/sim/lib/copilot/request/session/event.test.ts index 0f0573a24f8..29d86146c1a 100644 --- a/apps/sim/lib/copilot/request/session/event.test.ts +++ b/apps/sim/lib/copilot/request/session/event.test.ts @@ -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', }, }) }) diff --git a/apps/sim/lib/copilot/request/session/writer.test.ts b/apps/sim/lib/copilot/request/session/writer.test.ts index aa3eb5384ea..8ff64276df1 100644 --- a/apps/sim/lib/copilot/request/session/writer.test.ts +++ b/apps/sim/lib/copilot/request/session/writer.test.ts @@ -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) diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts index 65b5b4319c4..d8da8edc0c2 100644 --- a/apps/sim/lib/copilot/request/sse-utils.test.ts +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -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) }) diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index b40964ab425..4bc032fae61 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -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) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 01845830153..f4712a6fffe 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -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 = 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 = 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, diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 19c04e383e7..7a6b94dbe81 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -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. diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 035ca0b85d0..22f1a641f9d 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -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 = new Set([FunctionExecute.id, UserTable.id]) +export const OUTPUT_PATH_TOOLS: Set = 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 = { } /** - * 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. diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/copilot/request/tools/permission.test.ts index 5c75b645730..58a6e6c8113 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/copilot/request/tools/permission.test.ts @@ -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 () => { diff --git a/apps/sim/lib/copilot/request/tools/permissions.ts b/apps/sim/lib/copilot/request/tools/permissions.ts index 2d12c12ab13..fcd1e8d9341 100644 --- a/apps/sim/lib/copilot/request/tools/permissions.ts +++ b/apps/sim/lib/copilot/request/tools/permissions.ts @@ -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( diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 26bff98ec4c..536ac1adf87 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -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() diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 57347739a4c..ba68f10f7c2 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -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() diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index 46d4d598e60..36eecdc2209 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -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 { - 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 diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index ed76e5cd505..580b17238ff 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -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 diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 65e8d40bc61..c47413711f2 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -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, diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 2a614d944b6..fd1782a59f6 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -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 = 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 = { - [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 = { 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[] => { diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 7edc630d199..bc25956d3dc 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -20,6 +20,8 @@ export interface MothershipResource { id: string title: string path?: string + /** Saved table view to open pinned (type "table" only). */ + viewId?: string } /** diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 11672c1e8ed..276585eeb78 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -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, }), diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 6184d682204..969e4275355 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -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 diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f2f9eb6d304..61276eb7527 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -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 { [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 { [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 { [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(), diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 7a849821895..788b42781e6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -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' diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts index 9a877e34169..178d403603b 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts @@ -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') ) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index e37f623a234..1be8a3f514f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -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() }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 58f1ac5464b..c76fdf32b78 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts index 01727a9c9d0..cd5b1ec5cd1 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts @@ -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', }) ) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 5fd15db45d7..d5a0260f2dc 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -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' } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index 0ec758912f6..f8768765337 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -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)', } } diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 0717900344a..f85769f99d9 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -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 () => { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 01773a65f00..7580c3382da 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -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 } : {}), diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index a0c5cbb9089..7d43faa71b0 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index 5158176f27c..da6dc0cdd43 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -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, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index 65af6a518cb..45eb7b9fb96 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -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() }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index d6b5e2dece6..26f87478e51 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -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, diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index a7ece423150..afe24b1b1ed 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -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 } diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts index c3c3ac14384..c7f02974520 100644 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 4807406e830..64fa0d7610e 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -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') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 743c67b7784..0bbb90efe0d 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -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 }, + } } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts index e27babc4512..68345ea7527 100644 --- a/apps/sim/lib/copilot/tools/handlers/run-code.ts +++ b/apps/sim/lib/copilot/tools/handlers/run-code.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts index 3e511d1be0c..633a4bacdd5 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts @@ -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() }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 3245a6b8977..2d2d6880dbd 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -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 () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 8fa50ce6d6f..946cabe015f 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -54,7 +54,7 @@ const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'know const CATEGORY_REJECTIONS: Record = { 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.', } diff --git a/apps/sim/lib/copilot/tools/permissions.test.ts b/apps/sim/lib/copilot/tools/permissions.test.ts index a15ee1f791a..3edfc033dff 100644 --- a/apps/sim/lib/copilot/tools/permissions.test.ts +++ b/apps/sim/lib/copilot/tools/permissions.test.ts @@ -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." ) }) }) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts index 0f574e56112..f65a8b2ab70 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index 2af97ba7490..b4081e340a2 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -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). */ diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts index 14693f75913..f024b421de8 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts @@ -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, diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index ad14c3937a6..1a61dcbbfc0 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -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 = { - name: SearchDocumentation.id, + name: SearchSimDocs.id, async execute(params: DocsSearchParams): Promise { const logger = createLogger('SearchDocumentationServerTool') const { query, topK = 10, threshold } = params diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts index bde0e4873b5..8b7a2a3d7ef 100644 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts @@ -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 = { - name: EnrichmentRun.id, + name: RunEnrichment.id, async execute(params: EnrichmentRunParams, context): Promise { const logger = createLogger('EnrichmentRunServerTool') const { enrichmentId, inputs } = params diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 430545f8b0a..8f36784b1a1 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -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 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'); diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index ff1f7b10f26..dba8bb892b8 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -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, diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 0f453ebd678..99665a3375e 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -30,10 +30,10 @@ type EditContentResult = { } export const editContentServerTool: BaseServerTool = { - name: 'edit_content', + name: 'apply_file_edit', async execute(params: EditContentArgs, context?: ServerToolContext): Promise { 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 { }) ) - // 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') }) diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts index 82f7977b17d..e9841ce42d9 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index d99f219021b..f706231d5ee 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -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 diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index f40fbe00652..6ac90e138d3 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -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 = { - name: WorkspaceFile.id, + name: PrepareFileEdit.id, async execute( params: WorkspaceFileArgs, context?: ServerToolContext @@ -478,7 +478,7 @@ export const workspaceFileServerTool: BaseServerTool ({ - 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({ diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 1f5aa48d4c5..cd9f300a2ad 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -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 = { - name: KnowledgeBase.id, + name: ManageKnowledgeBase.id, async execute( params: KnowledgeBaseArgs, context?: ServerToolContext @@ -1070,7 +1070,7 @@ export const knowledgeBaseServerTool: BaseServerTool ({ })) vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchOnline: { id: 'search_online' }, + WebSearch: { id: 'web_search' }, })) vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 330d930c649..272c80d1035 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -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 = { - name: SearchOnline.id, + name: WebSearch.id, async execute(params: OnlineSearchParams, context?: ServerToolContext): Promise { const logger = createLogger('SearchOnlineServerTool') const { query, num = 10, type = 'search', gl, hl } = params diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 7d87b432218..66848fa4688 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -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 = { - [KnowledgeBase.id]: [ + [ManageKnowledgeBase.id]: [ 'create', 'add_file', 'update', @@ -133,19 +134,19 @@ const WRITE_ACTIONS: Record = { '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 = { [tableColumnsServerTool.name]: tableColumnsServerTool, [tableAutomationsServerTool.name]: tableAutomationsServerTool, [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, + [tableViewsServerTool.name]: tableViewsServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts new file mode 100644 index 00000000000..56d4df7e797 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.ts b/apps/sim/lib/copilot/tools/server/table/table-views.ts new file mode 100644 index 00000000000..fe7cdaf27de --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/table-views.ts @@ -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 +} + +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 = { + 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)`, + } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 83c263815a2..88f2dd91568 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -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/) }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index aa03686ccba..8ced02221e3 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -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 = { name: UserTable.id, async execute(params: UserTableArgs, context?: ServerToolContext): Promise { @@ -398,6 +414,31 @@ export const userTableServerTool: BaseServerTool 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 { 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 // 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` diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 027ce68d915..31520688721 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -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'], }) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index f15c80c5224..61a3096cb32 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -449,36 +449,37 @@ const TOOL_TITLES: Record = { 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 = { 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 = { 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 = { 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 } 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 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 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 } 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 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 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 ) 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 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 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 } 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 } diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 0c3dd20a16a..6db6bc0319c 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -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. */ diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 0fc76e89926..cb5348a061d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -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: ""}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).', + }, + null, + 2 + ) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 96ca9331725..f7ccc552e82 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -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 { 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), + 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({ diff --git a/apps/sim/lib/folders/application/resource-vfs.ts b/apps/sim/lib/folders/application/resource-vfs.ts index 8c997fa7f19d5505692b5757a935e207dd50d1a4..00afc85937eb91839e923afdf233a471dc2e4ca4 100644 GIT binary patch delta 130 zcmaD-^s{J#tpsmbVoqjCVo7Fxp1Ka#gnYj?co6BT`Igt4iRE{ws^JCS- c*pc~Fx-%2%{H$$aYZrnw|OQb0NUO%9RL6T delta 109 zcmexa^rUEmtprzQURh#JW{SEF*W?K%hMTJ;zB98!#WvT-2y?(WGgXc;!a130V(c)^ a { expect(await getTableView('view-elsewhere', 'table-1', columns)).toBeNull() }) }) + +describe('view config name/id translation', () => { + const columns = [ + { id: 'col_a', name: 'status', type: 'string' }, + { id: 'col_b', name: 'due', type: 'date' }, + ] as never[] + + it('round-trips a config between id and name domains', async () => { + const { viewConfigIdsToNames, viewConfigNamesToIds } = await import('@/lib/table/views/service') + const stored = { + filter: { + any: [ + { field: 'col_a', op: 'eq', value: 'Open' }, + { all: [{ field: 'col_b', op: 'isNotNull' }] }, + ], + }, + sort: [{ field: 'col_b', direction: 'desc' }], + hiddenColumns: ['col_a'], + } as never + const named = viewConfigIdsToNames(stored, columns as never) + expect(named.filter).toEqual({ + any: [ + { field: 'status', op: 'eq', value: 'Open' }, + { all: [{ field: 'due', op: 'isNotNull' }] }, + ], + }) + expect(named.sort).toEqual([{ field: 'due', direction: 'desc' }]) + expect(named.hiddenColumns).toEqual(['status']) + expect(viewConfigNamesToIds(named, columns as never)).toEqual(stored) + }) + + it('passes stale ids through on read but rejects unknown names on write', async () => { + const { viewConfigIdsToNames, viewConfigNamesToIds } = await import('@/lib/table/views/service') + const withStale = { filter: { all: [{ field: 'col_gone', op: 'isNull' }] } } as never + expect( + ( + viewConfigIdsToNames(withStale, columns as never).filter as never as { + all: { field: string }[] + } + ).all[0].field + ).toBe('col_gone') + expect(() => + viewConfigNamesToIds( + { filter: { all: [{ field: 'nope', op: 'isNull' }] } } as never, + columns as never + ) + ).toThrow(/Unknown column/) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index de0c8c24118..b75b36de916 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -336,3 +336,85 @@ export async function deleteTableView( } return deleted.length > 0 } + +/** + * All of a workspace's views in one query, keyed by tableId — the snapshot + * materializer's shape (per-table listTableViews would be N queries). Configs + * are returned RAW (id-domain, unpruned); callers translate/prune with each + * table's own columns. + */ +export async function listTableViewsByWorkspace( + workspaceId: string +): Promise>> { + const rows = await db + .select() + .from(tableViews) + .where(eq(tableViews.workspaceId, workspaceId)) + .orderBy(asc(tableViews.createdAt), asc(tableViews.id)) + const byTable = new Map>() + for (const row of rows) { + const list = byTable.get(row.tableId) ?? [] + list.push(row) + byTable.set(row.tableId, list) + } + return byTable +} + +function mapPredicateFields( + node: PredicateNode, + mapField: (field: string) => string +): PredicateNode { + if ('all' in node) return { all: node.all.map((child) => mapPredicateFields(child, mapField)) } + if ('any' in node) return { any: node.any.map((child) => mapPredicateFields(child, mapField)) } + const leaf = node as Predicate + return { ...leaf, field: mapField(leaf.field) } +} + +/** + * Stored (id-domain) view config → the column-NAME domain agents speak. + * Unknown ids pass through unchanged, mirroring pruneViewConfig's philosophy + * for filters: surfacing a stale reference beats silently widening the view. + */ +export function viewConfigIdsToNames( + config: TableViewConfig, + columns: ColumnDefinition[] +): TableViewConfig { + const nameById = new Map(columns.map((col) => [getColumnId(col), col.name])) + const toName = (field: string) => nameById.get(field) ?? field + const out: TableViewConfig = { ...config } + if (config.filter) out.filter = mapPredicateFields(config.filter, toName) as typeof config.filter + if (config.sort) out.sort = config.sort.map((s) => ({ ...s, field: toName(s.field) })) + if (config.hiddenColumns) out.hiddenColumns = config.hiddenColumns.map(toName) + return out +} + +/** + * Agent-supplied (name-domain) view config → the id-domain stored shape. + * Unknown column names are an error — a saved view with a dangling reference + * is exactly the artifact this translation exists to prevent. + */ +export function viewConfigNamesToIds( + config: TableViewConfig, + columns: ColumnDefinition[] +): TableViewConfig { + const idByName = new Map(columns.map((col) => [col.name, getColumnId(col)])) + const unknown = new Set() + const toId = (field: string) => { + const id = idByName.get(field) + if (!id) { + unknown.add(field) + return field + } + return id + } + const out: TableViewConfig = { ...config } + if (config.filter) out.filter = mapPredicateFields(config.filter, toId) as typeof config.filter + if (config.sort) out.sort = config.sort.map((s) => ({ ...s, field: toId(s.field) })) + if (config.hiddenColumns) out.hiddenColumns = config.hiddenColumns.map(toId) + if (unknown.size > 0) { + throw new TableViewValidationError( + `Unknown column(s): ${[...unknown].join(', ')}. Use exact column names from get_schema.` + ) + } + return out +} diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 91fe18f2de0..a8484f0049f 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -485,7 +485,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { }) it('rolls back the folders it created when an upload fails mid-extraction', async () => { - // `materialize_file` refuses to re-extract into a root folder that still has any + // `save_upload` refuses to re-extract into a root folder that still has any // child, so a folder left behind by a failed run turns every retry into // "already extracted" until a human deletes the tree by hand. const buffer = await buildZip({ 'a/one.txt': 'first', 'b/two.txt': 'second' }) diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 561d48330f0..b6894fc27bc 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -350,7 +350,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( // Uploads themselves can still fail mid-loop (storage/DB errors, quota crossed // by another writer), so a failure rolls back every file written so far *and* // every folder this call materialized — callers and their retries must never - // observe a partial tree. Leftover folders are not cosmetic: `materialize_file` + // observe a partial tree. Leftover folders are not cosmetic: `save_upload` // refuses to re-extract into a root folder that still has any child, so a // half-extracted tree would make every retry fail until a human deletes it. const folderIdCache = new Map() diff --git a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts index 50d15712462..24750a15f7c 100644 --- a/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts @@ -417,7 +417,7 @@ describe('trackChatUpload', () => { /** * The ownership lookup and the write are separate statements, so a - * concurrent `materialize_file` can flip the row to context='workspace' + * concurrent `save_upload` can flip the row to context='workspace' * in between. The UPDATE must re-assert every ownership predicate rather * than matching on the captured row id alone, or it would drag a saved * workspace file back into chat scope. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index cc83cc5e98d..ebadf170bca 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -930,7 +930,7 @@ export async function trackChatUpload( if (updated.length === 0) { // The ownership lookup is a separate statement, so re-assert every // predicate here — this UPDATE is the atomic check. A concurrent - // `materialize_file` flips the same row to context='workspace' and + // `save_upload` flips the same row to context='workspace' and // clears chatId; matching on id alone would drag that saved file back // into chat scope, hiding it from the Files listing and re-exposing it // to the chat-delete cascade. diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 6d04e92c7ef..89324ad340c 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -294,7 +294,7 @@ export function isArchiveFileName(filename: string): boolean { * `files/`, so this points at the explicit one-time extract step. */ export function buildArchiveExtractGuidance(name: string): string { - return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with materialize_file(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` + return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with save_upload(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` } const EXTENSION_TO_MIME: Record = { diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index b400fa621a1..8729908061f 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -97,7 +97,7 @@ export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm'] a /** * Archive formats accepted as chat attachments. A `.zip` is stored once in - * uploads/; the agent must extract it (materialize_file operation "extract") to + * uploads/; the agent must extract it (save_upload operation "extract") to * decompress it into workspace files/ before reading its contents. */ export const SUPPORTED_ARCHIVE_EXTENSIONS = ['zip'] as const @@ -229,7 +229,7 @@ export const CHAT_ACCEPT_ATTRIBUTE = [ /** * Accept attribute for the mothership copilot input only. Archives are scoped * here — NOT in {@link CHAT_ACCEPT_ATTRIBUTE} — because only the copilot flow - * has zip handling (materialize_file "extract"); a zip picked in a workflow or + * has zip handling (save_upload "extract"); a zip picked in a workflow or * deployed chat would flow into execution, where no parser exists. */ export const MOTHERSHIP_ACCEPT_ATTRIBUTE = [ diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 4056e4db2c4..9df7d48812a 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -244,7 +244,7 @@ export async function listCustomBlocksWithInputs( /** * The custom block bound to a workflow (with live-derived input fields), or `null` * when the workflow isn't published as a block. One block per workflow is enforced - * at publish time. Used by the copilot deploy_custom_block tool. + * at publish time. Used by the copilot publish_custom_block tool. */ export async function getCustomBlockWithInputsByWorkflowId( workflowId: string diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts index 098305930de..612ae65b3ee 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.test.ts @@ -46,7 +46,7 @@ describe('performChatDeploy password guards', () => { }) /** - * The copilot `deploy_chat` tool reaches this function without a route + * The copilot `deploy_as_chat` tool reaches this function without a route * contract, so these guards are the only thing standing between an agent and * a deployment nobody can log into. */ diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 6a28925145b..f4388208a89 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -60,7 +60,7 @@ export interface PerformChatDeployResult { * Deploys a chat: deploys the underlying workflow via `performFullDeploy`, * encrypts passwords, creates or updates the chat record, fires telemetry, * and records an audit entry. Both the chat API route and the copilot - * `deploy_chat` tool must use this function. + * `deploy_as_chat` tool must use this function. */ export async function performChatDeploy( params: ChatDeployPayload @@ -81,7 +81,7 @@ export async function performChatDeploy( /** * Validate the password here rather than only at the HTTP boundary. The - * copilot `deploy_chat` tool reaches this function without going through a + * copilot `deploy_as_chat` tool reaches this function without going through a * route contract, so a whitespace-only or over-long password would otherwise * be encrypted and stored — and neither can ever be submitted through the * chat login form, permanently locking visitors out of the deployment. @@ -171,7 +171,7 @@ export async function performChatDeploy( /** * A password-protected chat must end up with a stored password. Both HTTP * routes already reject this; without the same guard here a copilot - * `deploy_chat` call could create one with no password, which fails closed at + * `deploy_as_chat` call could create one with no password, which fails closed at * login with an opaque "Authentication configuration error". */ if (authType === 'password' && !encryptedPassword && !existingDeployment?.password) { @@ -309,7 +309,7 @@ export interface PerformChatUndeployResult { /** * Undeploys a chat: deletes the chat record and records an audit entry. - * Both the chat manage DELETE route and the copilot `deploy_chat` undeploy + * Both the chat manage DELETE route and the copilot `deploy_as_chat` undeploy * action must use this function. */ export async function performChatUndeploy( From 733e5220f23ade755a55f421b4e75307b9afcfb5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 13:33:41 -0700 Subject: [PATCH 009/135] Expand workflow log query support --- apps/sim/lib/api/contracts/logs.ts | 4 + .../lib/copilot/generated/tool-catalog-v1.ts | 61 +++-- .../lib/copilot/generated/tool-schemas-v1.ts | 62 +++-- .../tools/server/workflow/query-logs.test.ts | 108 +++++++-- .../tools/server/workflow/query-logs.ts | 110 +++++++-- .../lib/copilot/tools/tool-display.test.ts | 4 +- apps/sim/lib/copilot/tools/tool-display.ts | 40 +++- apps/sim/lib/logs/list-logs.ts | 33 +++ apps/sim/lib/logs/log-views.test.ts | 73 +++++- apps/sim/lib/logs/log-views.ts | 117 +++++++++- apps/sim/lib/logs/stats-logs.ts | 214 ++++++++++++++++++ 11 files changed, 753 insertions(+), 73 deletions(-) create mode 100644 apps/sim/lib/logs/stats-logs.ts diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index b269d8c75f5..71677a738cb 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -38,6 +38,8 @@ export const listLogsQuerySchema = logFilterQuerySchema.extend({ limit: z.coerce.number().int().min(1).max(200).optional().default(100), sortBy: logSortBySchema, sortOrder: logSortOrderSchema, + /** Also run a COUNT(*) under the same filters and return it as `total`. */ + includeTotal: z.coerce.boolean().optional(), }) export const logDetailQuerySchema = z.object({ @@ -294,6 +296,8 @@ export type WorkflowLogRow = WorkflowLogSummary & export const listLogsResponseSchema = z.object({ data: z.array(workflowLogSummarySchema), nextCursor: z.string().nullable(), + /** Total rows matching the filters; present only when `includeTotal` was set. */ + total: z.number().optional(), }) export type ListLogsResponse = z.output diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 82ec12ead2a..9ba13a5aab6 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3478,7 +3478,7 @@ export const OpenResource: ToolCatalogEntry = { view: { type: 'string', description: - 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + 'Saved table view to open pinned (type "table" only): a view ID from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', }, }, required: ['type'], @@ -3760,10 +3760,22 @@ export const QueryLogs: ToolCatalogEntry = { type: 'string', description: "Optional (view='full'): only return this block's span subtree.", }, + blockIds: { + type: 'array', + description: + "(view='full') Block ids to drill into, copied from the trace digest's blockId values. Preferred over blockName; several at once is fine.", + items: { type: 'string' }, + }, blockName: { type: 'string', description: "Optional (view='full'): only return spans for this block name.", }, + bucket: { + type: 'string', + description: + "(view='stats') Calendar bucketing for the per-workflow series: 'day' or 'hour'. Omit for overall totals only.", + enum: ['day', 'hour'], + }, costOperator: { type: 'string', description: "Filter (view='list'): comparison operator for cost.", @@ -3786,24 +3798,34 @@ export const QueryLogs: ToolCatalogEntry = { type: 'number', description: "Filter (view='list'): duration threshold (ms) paired with durationOperator.", }, - endDate: { type: 'string', description: "Filter (view='list'): ISO end of the time range." }, + endDate: { + type: 'string', + description: "Filter (view='list'/'stats'): ISO end of the time range.", + }, executionId: { type: 'string', description: - "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + "Required for 'trace'/'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + fields: { + type: 'array', + description: + "(view='full') Only load these payload fields per span: whole keys ('input', 'output', 'error') or dotted paths into them ('output.result.rows', 'input.query'). Dotted selections come back under 'selected' keyed by path. Use this to pull just the field you need instead of a block's entire I/O.", + items: { type: 'string' }, }, folderIds: { type: 'string', - description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + description: + "Filter (view='list'/'stats'): comma-separated folder IDs (descendants included).", }, folderName: { type: 'string', - description: "Filter (view='list'): substring match on folder name.", + description: "Filter (view='list'/'stats'): substring match on folder name.", }, level: { type: 'string', description: - "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + "Filter (view='list'/'stats'): comma-separated levels: error, info, running, pending. Default all.", }, limit: { type: 'number', description: "Max results (view='list'), 1-200 (default 100)." }, pattern: { @@ -3827,29 +3849,38 @@ export const QueryLogs: ToolCatalogEntry = { }, startDate: { type: 'string', - description: "Filter (view='list'): ISO start of the time range.", + description: "Filter (view='list'/'stats'): ISO start of the time range.", + }, + timezone: { + type: 'string', + description: + '(view=\'stats\') IANA timezone the buckets are computed in, e.g. "America/Los_Angeles". Defaults to UTC. Set this whenever the user\'s question is about "today"/"yesterday" in their local time.', + }, + title: { + type: 'string', + description: + 'Short human-readable label for this query, shown as the tool row in the UI, e.g. "Counting Elder failures Aug 12-13" or "Reading the failed enrichment run". Always provide one — it is how the user follows what you are looking for.', }, triggers: { type: 'string', - description: "Filter (view='list'): comma-separated trigger types.", + description: "Filter (view='list'/'stats'): comma-separated trigger types.", }, view: { type: 'string', description: - "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", - enum: ['list', 'overview', 'full'], + "Disclosure level: 'stats' (aggregate counts), 'list' (summaries), 'trace' (one execution's condensed block digest), 'overview' (trace tree, no I/O), 'full' (spans with I/O). Defaults to 'trace' with executionId, else 'list'.", + enum: ['list', 'stats', 'trace', 'overview', 'full'], }, workflowIds: { type: 'string', - description: "Filter (view='list'): comma-separated workflow IDs.", + description: "Filter (view='list'/'stats'): comma-separated workflow IDs.", }, workflowName: { type: 'string', - description: "Filter (view='list'): substring match on workflow name.", + description: "Filter (view='list'/'stats'): substring match on workflow name.", }, workspaceId: { type: 'string', description: 'Workspace ID to scope to.' }, }, - required: ['view'], }, } @@ -3890,7 +3921,7 @@ export const QueryUserTable: ToolCatalogEntry = { view: { type: 'string', description: - "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + "Saved view to query through (query_rows only): a view ID from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", }, }, }, @@ -5437,7 +5468,7 @@ export const TableViews: ToolCatalogEntry = { name: { type: 'string', description: - "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { type: 'array', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 50d9fe521a5..3bebb7cd523 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3321,7 +3321,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { view: { type: 'string', description: - 'Saved table view to open pinned (type "table" only): a view id or exact view name from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', + 'Saved table view to open pinned (type "table" only): a view ID from the table\'s views.json. The panel opens the table with that view\'s filter/sort active. Omit to open the table on its default view.', }, }, required: ['type'], @@ -3609,10 +3609,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: "Optional (view='full'): only return this block's span subtree.", }, + blockIds: { + type: 'array', + description: + "(view='full') Block ids to drill into, copied from the trace digest's blockId values. Preferred over blockName; several at once is fine.", + items: { + type: 'string', + }, + }, blockName: { type: 'string', description: "Optional (view='full'): only return spans for this block name.", }, + bucket: { + type: 'string', + description: + "(view='stats') Calendar bucketing for the per-workflow series: 'day' or 'hour'. Omit for overall totals only.", + enum: ['day', 'hour'], + }, costOperator: { type: 'string', description: "Filter (view='list'): comparison operator for cost.", @@ -3638,25 +3652,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, endDate: { type: 'string', - description: "Filter (view='list'): ISO end of the time range.", + description: "Filter (view='list'/'stats'): ISO end of the time range.", }, executionId: { type: 'string', description: - "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + "Required for 'trace'/'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + fields: { + type: 'array', + description: + "(view='full') Only load these payload fields per span: whole keys ('input', 'output', 'error') or dotted paths into them ('output.result.rows', 'input.query'). Dotted selections come back under 'selected' keyed by path. Use this to pull just the field you need instead of a block's entire I/O.", + items: { + type: 'string', + }, }, folderIds: { type: 'string', - description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + description: + "Filter (view='list'/'stats'): comma-separated folder IDs (descendants included).", }, folderName: { type: 'string', - description: "Filter (view='list'): substring match on folder name.", + description: "Filter (view='list'/'stats'): substring match on folder name.", }, level: { type: 'string', description: - "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + "Filter (view='list'/'stats'): comma-separated levels: error, info, running, pending. Default all.", }, limit: { type: 'number', @@ -3683,32 +3706,41 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, startDate: { type: 'string', - description: "Filter (view='list'): ISO start of the time range.", + description: "Filter (view='list'/'stats'): ISO start of the time range.", + }, + timezone: { + type: 'string', + description: + '(view=\'stats\') IANA timezone the buckets are computed in, e.g. "America/Los_Angeles". Defaults to UTC. Set this whenever the user\'s question is about "today"/"yesterday" in their local time.', + }, + title: { + type: 'string', + description: + 'Short human-readable label for this query, shown as the tool row in the UI, e.g. "Counting Elder failures Aug 12-13" or "Reading the failed enrichment run". Always provide one — it is how the user follows what you are looking for.', }, triggers: { type: 'string', - description: "Filter (view='list'): comma-separated trigger types.", + description: "Filter (view='list'/'stats'): comma-separated trigger types.", }, view: { type: 'string', description: - "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", - enum: ['list', 'overview', 'full'], + "Disclosure level: 'stats' (aggregate counts), 'list' (summaries), 'trace' (one execution's condensed block digest), 'overview' (trace tree, no I/O), 'full' (spans with I/O). Defaults to 'trace' with executionId, else 'list'.", + enum: ['list', 'stats', 'trace', 'overview', 'full'], }, workflowIds: { type: 'string', - description: "Filter (view='list'): comma-separated workflow IDs.", + description: "Filter (view='list'/'stats'): comma-separated workflow IDs.", }, workflowName: { type: 'string', - description: "Filter (view='list'): substring match on workflow name.", + description: "Filter (view='list'/'stats'): substring match on workflow name.", }, workspaceId: { type: 'string', description: 'Workspace ID to scope to.', }, }, - required: ['view'], }, resultSchema: undefined, }, @@ -3751,7 +3783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { view: { type: 'string', description: - "Saved view to query through (query_rows only): a view id or exact view name from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", + "Saved view to query through (query_rows only): a view ID from the table's views.json. The view's saved filter ANDs with any filter you pass (query-within-the-view); its saved sort applies only when you pass no order. Layout fields (hidden columns, widths) are ignored — full rows come back. Manage views via the table agent.", }, }, }, @@ -5338,7 +5370,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - "View display name (required for create_view; optional rename on update_view). Free-form label, need not be unique — prefer distinct names so query_user_table's view argument can use them unambiguously.", + 'View display name (required for create_view; optional rename on update_view). Free-form label; references always use the view ID, so names are purely display.', }, sort: { type: 'array', diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts index 6f195ac4a30..d4c5541ab29 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts @@ -4,21 +4,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listLogsMock, fetchLogDetailMock, toOverviewMock, toFullMock, grepSpansMock } = vi.hoisted( - () => ({ - listLogsMock: vi.fn(), - fetchLogDetailMock: vi.fn(), - toOverviewMock: vi.fn(), - toFullMock: vi.fn(), - grepSpansMock: vi.fn(), - }) -) +const { + listLogsMock, + statsLogsMock, + fetchLogDetailMock, + toOverviewMock, + toFullMock, + toTraceMock, + grepSpansMock, +} = vi.hoisted(() => ({ + listLogsMock: vi.fn(), + statsLogsMock: vi.fn(), + fetchLogDetailMock: vi.fn(), + toOverviewMock: vi.fn(), + toFullMock: vi.fn(), + toTraceMock: vi.fn(), + grepSpansMock: vi.fn(), +})) vi.mock('@/lib/logs/list-logs', () => ({ listLogs: listLogsMock })) +vi.mock('@/lib/logs/stats-logs', () => ({ statsLogs: statsLogsMock })) vi.mock('@/lib/logs/fetch-log-detail', () => ({ fetchLogDetail: fetchLogDetailMock })) vi.mock('@/lib/logs/log-views', () => ({ toOverview: toOverviewMock, toFull: toFullMock, + toTrace: toTraceMock, grepSpans: grepSpansMock, })) vi.mock('@/lib/execution/payloads/large-execution-value', () => ({ @@ -47,8 +57,8 @@ beforeEach(() => { }) describe('queryLogsServerTool', () => { - it('list view delegates to listLogs with workspaceId and no view field', async () => { - listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null }) + it('list view delegates to listLogs and leads with total and cursor', async () => { + listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null, total: 42 }) const result = await queryLogsServerTool.execute( { view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any, @@ -59,8 +69,68 @@ describe('queryLogsServerTool', () => { const [params, userId] = listLogsMock.mock.calls[0] expect(userId).toBe('user-1') expect(params.workspaceId).toBe('ws-1') + expect(params.includeTotal).toBe(true) expect(params).not.toHaveProperty('view') - expect(result).toEqual({ data: [{ id: 'log-1' }], nextCursor: null }) + expect(params).not.toHaveProperty('title') + expect(result).toEqual({ total: 42, nextCursor: null, data: [{ id: 'log-1' }] }) + expect(Object.keys(result as object)).toEqual(['total', 'nextCursor', 'data']) + }) + + it('stats view delegates to statsLogs with the workspace scoped in', async () => { + statsLogsMock.mockResolvedValue({ totals: { executions: 7 } }) + + const result = await queryLogsServerTool.execute( + { view: 'stats', bucket: 'day', timezone: 'UTC', workflowIds: 'wf-1' } as any, + ctx + ) + + expect(statsLogsMock).toHaveBeenCalledTimes(1) + const [params, userId] = statsLogsMock.mock.calls[0] + expect(userId).toBe('user-1') + expect(params).toMatchObject({ workspaceId: 'ws-1', bucket: 'day', workflowIds: 'wf-1' }) + expect(result).toEqual({ totals: { executions: 7 } }) + }) + + it('defaults to the condensed trace digest when only an executionId is given', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toTraceMock.mockReturnValue([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) + + const result: any = await queryLogsServerTool.execute({ executionId: 'exec-1' } as any, ctx) + + expect(toTraceMock).toHaveBeenCalledTimes(1) + expect(result.blocks).toEqual([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) + expect(toOverviewMock).not.toHaveBeenCalled() + expect(toFullMock).not.toHaveBeenCalled() + }) + + it('defaults to list when no executionId is given', async () => { + listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) + + await queryLogsServerTool.execute({} as any, ctx) + + expect(listLogsMock).toHaveBeenCalledTimes(1) + }) + + it('passes blockIds and fields through to toFull', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toFullMock.mockResolvedValue([{ id: 's1' }]) + + await queryLogsServerTool.execute( + { + view: 'full', + executionId: 'exec-1', + blockIds: ['blk-1', 'blk-2'], + fields: ['output.rows'], + } as any, + ctx + ) + + expect(toFullMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { blockId: undefined, blockIds: ['blk-1', 'blk-2'], blockName: undefined }, + ['output.rows'] + ) }) it('overview view returns the projected span tree', async () => { @@ -87,10 +157,16 @@ describe('queryLogsServerTool', () => { ctx ) - expect(toFullMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), { - blockId: 'blk-1', - blockName: undefined, - }) + expect(toFullMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + { + blockId: 'blk-1', + blockIds: undefined, + blockName: undefined, + }, + undefined + ) expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }]) expect(result.truncated).toBe(false) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 9ab073f7dd8..a60e2711404 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -8,7 +8,8 @@ import { } from '@/lib/execution/payloads/large-execution-value' import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' import { type ListLogsParams, listLogs } from '@/lib/logs/list-logs' -import { grepSpans, type LogViewContext, toFull, toOverview } from '@/lib/logs/log-views' +import { grepSpans, type LogViewContext, toFull, toOverview, toTrace } from '@/lib/logs/log-views' +import { statsLogs } from '@/lib/logs/stats-logs' import type { TraceSpan } from '@/lib/logs/types' const logger = createLogger('QueryLogsServerTool') @@ -21,8 +22,12 @@ const MAX_FULL_RESULT_BYTES = 512 * 1024 const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!=']) +/** Display-only label rendered in the UI tool row; never used server-side. */ +const displayTitle = z.string().optional() + const listArgsSchema = z.object({ view: z.literal('list'), + title: displayTitle, workspaceId: z.string().optional(), level: z.string().optional(), workflowIds: z.string().optional(), @@ -44,8 +49,33 @@ const listArgsSchema = z.object({ sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), }) +const statsArgsSchema = z.object({ + view: z.literal('stats'), + title: displayTitle, + workspaceId: z.string().optional(), + level: z.string().optional(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), + bucket: z.enum(['day', 'hour']).optional(), + timezone: z.string().optional(), +}) + +const traceArgsSchema = z.object({ + view: z.literal('trace'), + title: displayTitle, + workspaceId: z.string().optional(), + executionId: z.string(), +}) + const overviewArgsSchema = z.object({ view: z.literal('overview'), + title: displayTitle, workspaceId: z.string().optional(), executionId: z.string(), pattern: z.string().optional(), @@ -53,19 +83,38 @@ const overviewArgsSchema = z.object({ const fullArgsSchema = z.object({ view: z.literal('full'), + title: displayTitle, workspaceId: z.string().optional(), executionId: z.string(), blockId: z.string().optional(), + blockIds: z.array(z.string()).optional(), blockName: z.string().optional(), + fields: z.array(z.string()).optional(), pattern: z.string().optional(), }) -const queryLogsArgsSchema = z.discriminatedUnion('view', [ +const queryLogsViewsSchema = z.discriminatedUnion('view', [ listArgsSchema, + statsArgsSchema, + traceArgsSchema, overviewArgsSchema, fullArgsSchema, ]) +/** + * `view` defaults to the compact disclosure level: `trace` when an + * `executionId` is supplied, `list` otherwise. + */ +const queryLogsArgsSchema = z.preprocess((value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const record = value as Record + if (record.view === undefined) { + return { ...record, view: record.executionId ? 'trace' : 'list' } + } + } + return value +}, queryLogsViewsSchema) + type QueryLogsArgs = z.infer function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { @@ -100,11 +149,17 @@ function buildLogViewContext( * Consolidated execution/log read tool. * * - `view: "list"` — paginated execution summaries with the full Logs-UI filter - * set (reuses `listLogs`). + * set (reuses `listLogs`); always carries `total` for the filtered set. + * - `view: "stats"` — server-side aggregation (counts by status, per workflow, + * optionally calendar-bucketed) under the same filters; answers quantitative + * questions in one call instead of a paginate-and-count walk. + * - `view: "trace"` — one execution's condensed per-block digest: names, + * statuses, execution counts (loop iterations collapse), block ids to drill + * into. * - `view: "overview"` — a single execution's trace-span tree (timing + cost, * no input/output). * - `view: "full"` — a single execution's trace spans with materialized - * input/output, optionally scoped to one block via `blockId`/`blockName`. + * input/output, scoped via `blockIds` (from the trace digest) / `blockName`. * - `pattern` (with `overview`/`full`) — grep that execution's trace spans, * streaming large values chunk-by-chunk. */ @@ -112,7 +167,10 @@ export const queryLogsServerTool: BaseServerTool = { name: QueryLogs.id, inputSchema: queryLogsArgsSchema, outputSchema: z.unknown(), - async execute(args: QueryLogsArgs, context?: ServerToolContext): Promise { + async execute(rawArgs: QueryLogsArgs, context?: ServerToolContext): Promise { + // Re-parse so the compact-view default applies even when a caller bypasses + // the router's schema validation; idempotent on already-parsed args. + const args = queryLogsArgsSchema.parse(rawArgs) as QueryLogsArgs if (!context?.userId) { throw new Error('Unauthorized access') } @@ -120,10 +178,18 @@ export const queryLogsServerTool: BaseServerTool = { const workspaceId = resolveWorkspaceId(args, context) if (args.view === 'list') { - const { view: _view, ...rest } = args - const params = { ...rest, workspaceId } as ListLogsParams + const { view: _view, title: _title, ...rest } = args + const params = { ...rest, workspaceId, includeTotal: true } as ListLogsParams logger.info('query_logs list', { workspaceId, sortBy: params.sortBy }) - return listLogs(params, userId) + const { data, nextCursor, total } = await listLogs(params, userId) + // Cursor and total lead the payload so a truncated render still shows them. + return { total, nextCursor, data } + } + + if (args.view === 'stats') { + const { view: _view, title: _title, ...rest } = args + logger.info('query_logs stats', { workspaceId, bucket: rest.bucket }) + return statsLogs({ ...rest, workspaceId }, userId) } // overview / full / grep — single execution by id @@ -141,6 +207,18 @@ export const queryLogsServerTool: BaseServerTool = { | { traceSpans?: TraceSpan[]; totalDuration?: number | null } | undefined const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[] + + if (args.view === 'trace') { + return { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + trigger: detail.trigger, + durationMs: execData?.totalDuration ?? null, + blocks: toTrace(traceSpans), + } + } + const viewCtx = buildLogViewContext(detail, workspaceId, userId) if (args.pattern) { @@ -174,10 +252,16 @@ export const queryLogsServerTool: BaseServerTool = { } // full - const spans = await toFull(traceSpans, viewCtx, { - blockId: args.blockId, - blockName: args.blockName, - }) + const spans = await toFull( + traceSpans, + viewCtx, + { + blockId: args.blockId, + blockIds: args.blockIds, + blockName: args.blockName, + }, + args.fields + ) const result = { executionId: detail.executionId, workflowId: detail.workflowId, @@ -194,7 +278,7 @@ export const queryLogsServerTool: BaseServerTool = { workflowId: detail.workflowId, status: detail.status, truncated: true, - note: 'Full result too large; returning the compact overview. Scope with blockId/blockName, or use pattern to grep.', + note: 'Full result too large; returning the compact overview. Scope with blockIds/blockName (ids from view "trace"), or use pattern to grep.', spans: toOverview(traceSpans), } } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 31520688721..e28db5e1a2f 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -146,8 +146,8 @@ describe('getToolDisplayTitle for deployments', () => { ['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'], + ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], + ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], ['deploy_as_mcp', undefined, 'Deploying MCP tool'], ['redeploy', undefined, 'Redeploying API'], ])('uses the action and deployment type for %s', (toolName, args, expected) => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 61a3096cb32..b274fdd2bdf 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -352,6 +352,9 @@ function materializeFileTitle(args: ToolArgs): string { if (operation === 'import') { return `Importing ${summarizeTargets(targets, 'workflow')}` } + if (operation === 'extract') { + return `Extracting ${summarizeTargets(targets, 'archive')}` + } return `Saving ${summarizeTargets(targets, 'file')}` } @@ -468,7 +471,7 @@ const TOOL_TITLES: Record = { delete_workspace_mcp_server: 'Deleting MCP server', deploy_as_api: 'Deploying API', deploy_as_chat: 'Deploying chat', - publish_custom_block: 'Deploying custom block', + publish_custom_block: 'Publishing custom block', deploy_as_mcp: 'Deploying MCP tool', diff_workflows: 'Comparing workflows', download_file: 'Downloading file', @@ -478,8 +481,8 @@ const TOOL_TITLES: Record = { get_block_outputs: 'Getting block outputs', get_block_upstream_references: 'Getting block references', get_deployed_workflow_state: 'Getting deployed workflow', - list_deployment_versions: 'Getting deployment logs', - get_ui_reference: 'Getting platform actions', + list_deployment_versions: 'Listing deployment versions', + get_ui_reference: 'Reading UI reference', get_scheduled_task_logs: 'Reading scheduled task logs', get_workflow_data: 'Getting workflow data', get_workflow_run_options: 'Getting run options', @@ -488,7 +491,7 @@ const TOOL_TITLES: Record = { list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', - save_upload: 'Preparing file', + save_upload: 'Saving upload', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -504,7 +507,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_sim_docs: 'Searching documentation', + search_sim_docs: 'Searching Sim docs', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', @@ -681,7 +684,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_chat': return deploymentTitle(args, 'chat') case 'publish_custom_block': - return deploymentTitle(args, 'custom block') + return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': return ffmpegTitle(args) case 'manage_knowledge_base': @@ -949,8 +952,28 @@ export function getToolDisplayTitle(name: string, args?: Record case 'run_workflow_until_block': return 'Running workflow' case 'query_logs': { + // The model narrates its own query; the per-view titles are fallbacks. + const title = stringArg(args, 'title') + if (title) return title const workflowName = stringArg(args, 'workflowName') - return workflowName ? `Querying logs for ${workflowName}` : 'Querying logs' + const scope = workflowName ? ` for ${workflowName}` : '' + switch (stringArg(args, 'view')) { + case 'stats': + return `Analyzing run stats${scope}` + case 'trace': + return 'Reading execution trace' + case 'overview': + return 'Reading execution overview' + case 'full': + return 'Reading execution details' + case 'list': + return `Querying logs${scope}` + default: + // view is optional: executionId implies the trace digest default. + return stringArg(args, 'executionId') + ? 'Reading execution trace' + : `Querying logs${scope}` + } } case 'read': { if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { @@ -992,6 +1015,9 @@ const COMPLETED_VERB_REWRITES: Record = { Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', + Publishing: 'Published', + Unpublishing: 'Unpublished', + Analyzing: 'Analyzed', Disabling: 'Disabled', Downloading: 'Downloaded', Duplicating: 'Duplicated', diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts index c98b0e564f6..4a682260cee 100644 --- a/apps/sim/lib/logs/list-logs.ts +++ b/apps/sim/lib/logs/list-logs.ts @@ -174,6 +174,10 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: false }) if (commonFilters) workflowConditions.push(commonFilters) + // Snapshot the filter-only conditions (no pagination cursor) so an + // `includeTotal` count runs over the whole filtered set, not the tail. + const workflowFilterConditions = [...workflowConditions] + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) if (workflowCursorCond) workflowConditions.push(workflowCursorCond) @@ -233,6 +237,7 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< .limit(fetchSize) const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)] + let jobFilterConditions: SQL[] = jobConditions if (includeJobLogs) { if (p.level && p.level !== 'all') { @@ -303,6 +308,8 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< if (durationCond) jobConditions.push(durationCond) } + jobFilterConditions = [...jobConditions] + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) if (jobCursorCond) jobConditions.push(jobCursorCond) } @@ -451,8 +458,34 @@ export async function listLogs(params: ListLogsParams, userId: string): Promise< nextCursor = encodeCursor({ v: cursorV, id: last.id }) } + let total: number | undefined + if (p.includeTotal) { + const workflowCountQuery = dbReplica + .select({ count: sql`COUNT(*)` }) + .from(workflowExecutionLogs) + .leftJoin( + pausedExecutions, + eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) + ) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(and(...workflowFilterConditions)) + const jobCountQuery = includeJobLogs + ? dbReplica + .select({ count: sql`COUNT(*)` }) + .from(jobExecutionLogs) + .where(and(...jobFilterConditions)) + : Promise.resolve([{ count: 0 }]) + const [workflowCount, jobCount] = await Promise.all([workflowCountQuery, jobCountQuery]) + total = Number(workflowCount[0]?.count ?? 0) + Number(jobCount[0]?.count ?? 0) + } + return { data: page.map((row) => row.summary), nextCursor, + ...(total !== undefined ? { total } : {}), } } diff --git a/apps/sim/lib/logs/log-views.test.ts b/apps/sim/lib/logs/log-views.test.ts index de444485adc..5d79f1b1d0d 100644 --- a/apps/sim/lib/logs/log-views.test.ts +++ b/apps/sim/lib/logs/log-views.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ import { sleep } from '@sim/utils/helpers' import type { TraceSpan } from '@/lib/logs/types' -import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views' +import { grepSpans, type LogViewContext, toFull, toOverview, toTrace } from './log-views' const ctx: LogViewContext = { workspaceId: 'ws-1', @@ -286,3 +286,74 @@ describe('grepSpans', () => { expect(result.truncated).toBe(false) }) }) + +describe('toTrace', () => { + it('collapses loop iterations into one per-block digest line with status counts', () => { + const spans: TraceSpan[] = [ + span({ + id: 'loop', + blockId: 'blk-loop', + name: 'Loop', + type: 'loop', + children: [ + span({ id: 'i1', blockId: 'blk-agent', name: 'Agent', status: 'success', duration: 10 }), + span({ id: 'i2', blockId: 'blk-agent', name: 'Agent', status: 'success', duration: 20 }), + span({ id: 'i3', blockId: 'blk-agent', name: 'Agent', status: 'error', duration: 5 }), + ], + }), + ] + + const digest = toTrace(spans) + + expect(digest).toHaveLength(2) + expect(digest[1]).toMatchObject({ + blockId: 'blk-agent', + name: 'Agent', + executions: 3, + statuses: { success: 2, error: 1 }, + totalDurationMs: 35, + }) + }) + + it('never materializes refs', () => { + toTrace([span({ output: ref('big') as unknown as Record })]) + expect(materializeLargeValueRefMock).not.toHaveBeenCalled() + }) +}) + +describe('toFull field projection', () => { + it('narrows spans to whole payload keys', async () => { + const out = await toFull( + [span({ input: { a: 1 }, output: { b: 2 }, errorMessage: 'boom' })], + ctx, + undefined, + ['output', 'error'] + ) + expect(out[0]).toMatchObject({ output: { b: 2 }, error: 'boom' }) + expect(out[0]).not.toHaveProperty('input') + }) + + it('extracts dotted paths under selected', async () => { + const out = await toFull( + [span({ output: { result: { rows: [1, 2, 3], meta: 'big' } } })], + ctx, + undefined, + ['output.result.rows'] + ) + expect(out[0]).not.toHaveProperty('output') + expect((out[0] as { selected?: Record }).selected).toEqual({ + 'output.result.rows': [1, 2, 3], + }) + }) + + it('supports blockIds multi-select with field projection', async () => { + const spans: TraceSpan[] = [ + span({ id: 's1', blockId: 'blk-a', name: 'A', output: { keep: 1 } }), + span({ id: 's2', blockId: 'blk-b', name: 'B', output: { keep: 2 } }), + span({ id: 's3', blockId: 'blk-c', name: 'C', output: { drop: true } }), + ] + const out = await toFull(spans, ctx, { blockIds: ['blk-a', 'blk-b'] }, ['output']) + expect(out.map((s) => s.blockId)).toEqual(['blk-a', 'blk-b']) + expect(out[0].output).toEqual({ keep: 1 }) + }) +}) diff --git a/apps/sim/lib/logs/log-views.ts b/apps/sim/lib/logs/log-views.ts index 1f679ce53b0..2de94476921 100644 --- a/apps/sim/lib/logs/log-views.ts +++ b/apps/sim/lib/logs/log-views.ts @@ -77,6 +77,56 @@ export function toOverview(spans: TraceSpan[]): OverviewSpan[] { }) } +// --------------------------------------------------------------------------- +// Trace (Level 1.5): condensed per-block digest — names, statuses, counts. +// --------------------------------------------------------------------------- + +export interface TraceDigestEntry { + /** Block id when the spans carry one; the drill-in key for `full` blockIds. */ + blockId?: string + name: string + type: string + /** How many spans (loop iterations included) this block produced. */ + executions: number + /** Span count per status, e.g. { success: 498, error: 2 }. */ + statuses: Record + totalDurationMs: number +} + +/** + * Project trace spans to a flat per-block digest in first-execution order. + * Every span in the tree is counted (loop iterations collapse into their + * block's entry), so a 500-iteration loop is one line, not 500. Never + * materializes refs. + */ +export function toTrace(spans: TraceSpan[]): TraceDigestEntry[] { + const byKey = new Map() + const walk = (list: TraceSpan[]): void => { + for (const s of list) { + const key = s.blockId ?? `${s.type}:${s.name}` + let entry = byKey.get(key) + if (!entry) { + entry = { + ...(s.blockId ? { blockId: s.blockId } : {}), + name: s.name, + type: s.type, + executions: 0, + statuses: {}, + totalDurationMs: 0, + } + byKey.set(key, entry) + } + entry.executions++ + const status = s.status ?? 'unknown' + entry.statuses[status] = (entry.statuses[status] ?? 0) + 1 + entry.totalDurationMs += s.duration ?? 0 + if (s.children && s.children.length > 0) walk(s.children) + } + } + walk(spans) + return Array.from(byKey.values()) +} + // --------------------------------------------------------------------------- // Full (Level 3): block tree WITH materialized input/output. // --------------------------------------------------------------------------- @@ -92,6 +142,8 @@ export interface FullSpan extends OverviewSpan { export interface BlockSelector { blockId?: string + /** Multiple drill-in targets at once (ids from the trace digest). */ + blockIds?: string[] blockName?: string } @@ -104,19 +156,76 @@ export interface BlockSelector { export async function toFull( spans: TraceSpan[], ctx: LogViewContext, - selector?: BlockSelector + selector?: BlockSelector, + fields?: string[] ): Promise { const roots = selectSpans(spans, selector) - return Promise.all(roots.map((s) => fullSpan(s, ctx))) + const full = await Promise.all(roots.map((s) => fullSpan(s, ctx))) + if (!fields || fields.length === 0) return full + return full.map((s) => projectSpanFields(s, fields)) +} + +/** + * Narrows a full span to the requested fields so the caller loads only what it + * needs. A field is either a whole payload key (`input` / `output` / `error`) + * or a dotted path into one (`output.result.rows`); dotted selections land + * under `selected` keyed by the full path. Span identity/status/timing always + * stay, and children are projected recursively. + */ +function projectSpanFields(span: FullSpan, fields: string[]): FullSpan { + const node: FullSpan = { + id: span.id, + blockId: span.blockId, + name: span.name, + type: span.type, + status: span.status, + durationMs: span.durationMs, + startTime: span.startTime, + endTime: span.endTime, + } + if (span.cost) node.cost = span.cost + const selected: Record = {} + let hasSelected = false + for (const field of fields) { + if (field === 'input' || field === 'output' || field === 'error') { + if (span[field] !== undefined) node[field] = span[field] as never + continue + } + const [head, ...rest] = field.split('.') + if ((head === 'input' || head === 'output') && rest.length > 0) { + let value: unknown = span[head] + for (const key of rest) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + value = (value as Record)[key] + } else if (Array.isArray(value) && /^\d+$/.test(key)) { + value = value[Number(key)] + } else { + value = undefined + break + } + } + selected[field] = value + hasSelected = true + } + } + if (hasSelected) (node as FullSpan & { selected?: Record }).selected = selected + if (span.children && span.children.length > 0) { + node.children = span.children.map((c) => projectSpanFields(c, fields)) + } + return node } function selectSpans(spans: TraceSpan[], selector?: BlockSelector): TraceSpan[] { - if (!selector || (!selector.blockId && !selector.blockName)) return spans + if (!selector || (!selector.blockId && !selector.blockIds?.length && !selector.blockName)) { + return spans + } + const idSet = new Set(selector.blockIds ?? []) + if (selector.blockId !== undefined) idSet.add(selector.blockId) const out: TraceSpan[] = [] const walk = (list: TraceSpan[]): void => { for (const s of list) { const matches = - (selector.blockId !== undefined && s.blockId === selector.blockId) || + (s.blockId !== undefined && idSet.has(s.blockId)) || (selector.blockName !== undefined && s.name === selector.blockName) if (matches) { out.push(s) diff --git a/apps/sim/lib/logs/stats-logs.ts b/apps/sim/lib/logs/stats-logs.ts new file mode 100644 index 00000000000..6a8abc1c6ab --- /dev/null +++ b/apps/sim/lib/logs/stats-logs.ts @@ -0,0 +1,214 @@ +import { dbReplica } from '@sim/db' +import { workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, eq, sql } from 'drizzle-orm' +import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +/** + * Server-side aggregation over workflow execution logs for the copilot + * `query_logs` stats view: per-workflow, optionally calendar-bucketed counts by + * status, under the same filter set as the list view. Exists so a model never + * has to paginate the list and count client-side. Job (Sim-agent) executions + * are not included — same scope as the Logs dashboard stats. + */ + +export interface StatsLogsParams { + workspaceId: string + level?: string + workflowIds?: string + folderIds?: string + triggers?: string + startDate?: string + endDate?: string + search?: string + workflowName?: string + folderName?: string + /** Calendar bucketing for the per-workflow series; omit for totals only. */ + bucket?: 'day' | 'hour' + /** IANA timezone the buckets are computed in. Defaults to UTC. */ + timezone?: string +} + +export interface LogStatsBucket { + /** Bucket start in the requested timezone (naive local timestamp). */ + start: string + executions: number + byStatus: Record + avgDurationMs: number +} + +export interface WorkflowLogStats { + workflowId: string + workflowName: string + executions: number + byStatus: Record + avgDurationMs: number + buckets?: LogStatsBucket[] +} + +export interface LogStatsResponse { + bucket: 'day' | 'hour' | null + timezone: string + totals: { executions: number; byStatus: Record; avgDurationMs: number } + workflows: WorkflowLogStats[] + /** Set when more workflows matched than are returned (ordered by executions). */ + workflowsTruncated?: boolean +} + +const MAX_WORKFLOWS = 100 + +function assertValidTimezone(timezone: string): void { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }) + } catch { + throw new Error(`Invalid timezone: ${timezone}. Use an IANA name like "America/Los_Angeles".`) + } +} + +interface StatsAccumulator { + executions: number + byStatus: Record + durationSumMs: number + durationCount: number +} + +function newAccumulator(): StatsAccumulator { + return { executions: 0, byStatus: {}, durationSumMs: 0, durationCount: 0 } +} + +function accumulate(acc: StatsAccumulator, status: string, row: RawStatsRow): void { + acc.executions += Number(row.executions) + acc.byStatus[status] = (acc.byStatus[status] ?? 0) + Number(row.executions) + acc.durationSumMs += Number(row.durationSumMs) + acc.durationCount += Number(row.durationCount) +} + +function avgOf(acc: StatsAccumulator): number { + return acc.durationCount > 0 ? Math.round(acc.durationSumMs / acc.durationCount) : 0 +} + +interface RawStatsRow { + workflowId: string + workflowName: string + bucketStart: string | null + status: string | null + executions: number + durationSumMs: number + durationCount: number +} + +export async function statsLogs( + params: StatsLogsParams, + userId: string +): Promise { + const timezone = params.timezone ?? 'UTC' + assertValidTimezone(timezone) + const bucket = params.bucket ?? null + + const access = await checkWorkspaceAccess(params.workspaceId, userId) + if (!access.hasAccess) { + return { + bucket, + timezone, + totals: { executions: 0, byStatus: {}, avgDurationMs: 0 }, + workflows: [], + } + } + + const folderIds = params.folderIds + ? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + : params.folderIds + const p = { ...params, folderIds } + + const workspaceFilter = eq(workflowExecutionLogs.workspaceId, p.workspaceId) + const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: true }) + const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter + + const bucketExpr = bucket + ? sql< + string | null + >`to_char(date_trunc(${bucket}, ${workflowExecutionLogs.startedAt} AT TIME ZONE ${timezone}), 'YYYY-MM-DD"T"HH24:MI:SS')` + : sql`NULL` + + const rows = (await dbReplica + .select({ + workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + bucketStart: bucketExpr.as('bucket_start'), + status: workflowExecutionLogs.status, + executions: sql`COUNT(*)`, + durationSumMs: sql`COALESCE(SUM(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`, + durationCount: sql`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0)`, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .where(whereCondition) + .groupBy( + sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, + sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, + sql`bucket_start`, + workflowExecutionLogs.status + )) as RawStatsRow[] + + const totals = newAccumulator() + const byWorkflow = new Map< + string, + { workflowName: string; overall: StatsAccumulator; buckets: Map } + >() + + for (const row of rows) { + const status = row.status ?? 'unknown' + accumulate(totals, status, row) + let wf = byWorkflow.get(row.workflowId) + if (!wf) { + wf = { workflowName: row.workflowName, overall: newAccumulator(), buckets: new Map() } + byWorkflow.set(row.workflowId, wf) + } + accumulate(wf.overall, status, row) + if (bucket && row.bucketStart) { + let bucketAcc = wf.buckets.get(row.bucketStart) + if (!bucketAcc) { + bucketAcc = newAccumulator() + wf.buckets.set(row.bucketStart, bucketAcc) + } + accumulate(bucketAcc, status, row) + } + } + + const workflows: WorkflowLogStats[] = Array.from(byWorkflow.entries()) + .map(([workflowId, wf]) => ({ + workflowId, + workflowName: wf.workflowName, + executions: wf.overall.executions, + byStatus: wf.overall.byStatus, + avgDurationMs: avgOf(wf.overall), + ...(bucket + ? { + buckets: Array.from(wf.buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([start, acc]) => ({ + start, + executions: acc.executions, + byStatus: acc.byStatus, + avgDurationMs: avgOf(acc), + })), + } + : {}), + })) + .sort((a, b) => b.executions - a.executions) + + const truncated = workflows.length > MAX_WORKFLOWS + + return { + bucket, + timezone, + totals: { + executions: totals.executions, + byStatus: totals.byStatus, + avgDurationMs: avgOf(totals), + }, + workflows: truncated ? workflows.slice(0, MAX_WORKFLOWS) : workflows, + ...(truncated ? { workflowsTruncated: true } : {}), + } +} From 2c8acbf7c6d17f1c11355ab98f6dfd288b465523 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 14:34:20 -0700 Subject: [PATCH 010/135] checkpoint --- .../agent-group/agent-group.test.ts | 3 - .../components/agent-group/agent-group.tsx | 27 +++--- .../message-content/message-content.tsx | 1 - .../home/hooks/stream/turn-model.test.ts | 90 +++++++++++++++++++ .../home/hooks/stream/turn-model.ts | 17 +++- 5 files changed, 118 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 4808893b4eb..9f83098c06a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -110,7 +110,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -184,7 +183,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) @@ -206,7 +204,6 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, - isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 103e6ba6e4f..070734ddf74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -37,8 +37,6 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean - /** This group is the latest section in its parent sequence (drives collapse). */ - isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean } @@ -110,7 +108,6 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, - isCurrentSection = false, isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -123,17 +120,18 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and any of: the lane is open (the subagent is - // actively running), this is the current/latest section, or there is unresolved - // work. A finished group stays open until the NEXT section starts (it is no - // longer the latest), instead of collapsing the instant its own work resolves. - // Keying "still running" off the lane-open signal (not `resolved` alone) avoids - // a collapse/reopen flicker on parallel siblings: a subagent's tools all - // momentarily read "done" in the gap between its last search and its `respond` - // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open - // lane bridges that gap so the row never collapses mid-run. The turn ending - // (isStreaming false) collapses everything; a manual toggle pins the choice. - const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) + // Expand while the turn is live and the subagent is still working: the lane + // is open, or there is unresolved work. When the lane closes and the work + // resolves the group collapses — with parallel subagents, finished siblings + // fold away while the still-running ones stay open, instead of every group + // lingering expanded until the next section starts. Keying "still running" + // off the lane-open signal (not `resolved` alone) avoids a collapse/reopen + // flicker mid-run: a subagent's tools all momentarily read "done" in the gap + // between its last search and its `respond` ("Gathering thoughts") tool, + // transiently flipping `resolved` true; the open lane bridges that gap. The + // turn ending (isStreaming false) collapses everything; a manual toggle pins + // the choice. + const autoExpanded = isStreaming && (isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -219,7 +217,6 @@ export function AgentGroup({ items={item.group.items} isDelegating={item.group.isDelegating} isStreaming={isStreaming} - isCurrentSection={idx === items.length - 1} isLaneOpen={item.group.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 24dfc3fd842..033ecf0cd50 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -957,7 +957,6 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} - isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 971ecd57b85..1c5eb2d72d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -556,3 +556,93 @@ describe('reduceEvent — span-start owner reconciliation', () => { expect((lane as AgentNode).agentId).toBe('workflow') }) }) + +describe('reduceEvent — span end settles stale lane tools', () => { + const laneScope = { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } as Scope + + it('marks still-running tools success when their lane ends cleanly', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + // No result for click-1 — dropped/reordered past the lane end. + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('success') + + const laneId = model.agentBySpanId.get('S1') + const lane = laneId ? model.nodes.get(laneId) : undefined + if (lane?.kind !== 'agent') throw new Error('expected agent lane') + expect(lane.status).toBe('success') + }) + + it('marks still-running tools error when the lane ends with an error', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: { error: 'boom' } }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + }) + + it('leaves settled tools alone and lets a late result overwrite the settle', () => { + const model = apply([ + envelope( + 1, + 'span', + { kind: 'subagent', event: 'start', agent: 'browser', data: { tool_call_id: 'd1' } }, + laneScope + ), + toolCall(2, 'click-1', 'browser_click', laneScope), + envelope( + 3, + 'span', + { kind: 'subagent', event: 'end', agent: 'browser', data: {} }, + laneScope + ), + // Late result arrives after the settle — it must win. + envelope( + 4, + 'tool', + { + phase: 'result', + toolCallId: 'click-1', + toolName: 'browser_click', + success: false, + error: 'nope', + }, + laneScope + ), + ]) + + const click = model.nodes.get('click-1') + if (click?.kind !== 'tool') throw new Error('expected tool node') + expect(click.status).toBe('error') + expect(click.result?.error).toBe('nope') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index d636bf8b470..25651a0eb59 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -611,10 +611,25 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (data?.pending === true) break breakLane(model, resolvedSpanId, tsMs) const node = model.nodes.get(resolvedSpanId) + const spanErrored = Boolean(data && asString(data.error)) if (node && node.kind === 'agent' && !isNodeTerminal(node.status)) { - node.status = data && asString(data.error) ? 'error' : 'success' + node.status = spanErrored ? 'error' : 'success' node.endSeq = seq } + // The lane is over: settle any tool row still `running` in it (its + // result was dropped or reordered past the end). Left open, the row + // pins the whole group expanded and shimmering for the rest of the + // turn even though the subagent already returned. A late result event + // still corrects this — applyToolResult overwrites unconditionally. + for (const id of model.order) { + const stale = model.nodes.get(id) + if (stale?.kind === 'tool' && stale.spanId === resolvedSpanId) { + if (stale.status === 'running') { + stale.status = spanErrored ? 'error' : 'success' + stale.streamingArgs = undefined + } + } + } } break } From 46aae116b5a5638ea2b30c1bc8db6c56cd71d42a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 14:40:53 -0700 Subject: [PATCH 011/135] Port desktop-improvements-0 desktop and browser-agent work --- .../main/browser-search/suggestions.test.ts | 74 +++++++++++++ .../src/main/browser-search/suggestions.ts | 102 ++++++++++++++++++ apps/desktop/src/main/config.ts | 3 + .../desktop/src/main/desktop-settings.test.ts | 11 ++ apps/desktop/src/main/desktop-settings.ts | 7 ++ apps/desktop/src/main/ipc.test.ts | 23 ++++ apps/desktop/src/main/ipc.ts | 20 ++++ apps/desktop/src/preload/index.test.ts | 4 + apps/desktop/src/preload/index.ts | 4 + .../browser-session/browser-session.test.ts | 4 + .../browser-session/browser-session.tsx | 95 +++++++++++----- .../browser-session/url-suggestions.test.ts | 62 +++++++++++ .../browser-session/url-suggestions.ts | 55 ++++++++++ .../resource-tabs/resource-tabs.tsx | 4 +- .../app/workspace/[workspaceId]/home/home.tsx | 22 ++-- .../[workspaceId]/home/hooks/index.ts | 2 + .../[workspaceId]/home/hooks/use-chat.test.ts | 15 +++ .../[workspaceId]/home/hooks/use-chat.ts | 48 ++++++--- .../components/browser/browser.test.tsx | 42 +++++++- .../settings/components/browser/browser.tsx | 28 +++++ apps/sim/lib/browser-agent/open-in-panel.ts | 2 +- apps/sim/lib/browser-agent/transport.test.ts | 29 +++++ apps/sim/lib/browser-agent/transport.ts | 22 ++++ .../lib/copilot/chat/desktop-capabilities.ts | 3 + apps/sim/lib/copilot/chat/post.ts | 10 +- apps/sim/lib/desktop/index.test.ts | 38 +++++++ apps/sim/lib/desktop/index.ts | 27 +++-- packages/desktop-bridge/src/index.ts | 12 +++ 28 files changed, 708 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/main/browser-search/suggestions.test.ts create mode 100644 apps/desktop/src/main/browser-search/suggestions.ts create mode 100644 apps/sim/lib/copilot/chat/desktop-capabilities.ts diff --git a/apps/desktop/src/main/browser-search/suggestions.test.ts b/apps/desktop/src/main/browser-search/suggestions.test.ts new file mode 100644 index 00000000000..c9b839d25c0 --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + parseSearchSuggestionResponse, + SearchSuggestionService, +} from '@/main/browser-search/suggestions' + +describe('parseSearchSuggestionResponse', () => { + it('keeps unique non-empty completions and omits the exact query row', () => { + expect( + parseSearchSuggestionResponse( + [ + 'what is the be', + [ + 'what is the be', + 'what is the best sleeping position', + ' WHAT IS THE BEST SLEEPING POSITION ', + '', + 42, + 'what is the benefit of creatine', + ], + ], + 'what is the be' + ) + ).toEqual(['what is the best sleeping position', 'what is the benefit of creatine']) + }) + + it('rejects malformed provider payloads', () => { + expect(parseSearchSuggestionResponse(null, 'sim')).toEqual([]) + expect(parseSearchSuggestionResponse(['sim', {}], 'sim')).toEqual([]) + }) +}) + +describe('SearchSuggestionService', () => { + it('uses only the fixed provider URL and caches repeated queries', async () => { + const fetcher = vi.fn( + async (_url: string, _init: RequestInit): Promise => + Response.json(['sim studio', ['sim studio ai', 'sim studio workflow']]) + ) + const service = new SearchSuggestionService(fetcher, () => 1_000) + + await expect(service.suggest(' sim studio ')).resolves.toEqual([ + 'sim studio ai', + 'sim studio workflow', + ]) + await expect(service.suggest('SIM STUDIO')).resolves.toEqual([ + 'sim studio ai', + 'sim studio workflow', + ]) + + expect(fetcher).toHaveBeenCalledTimes(1) + const [url, init] = fetcher.mock.calls[0] + expect(new URL(url).origin).toBe('https://suggestqueries.google.com') + expect(new URL(url).searchParams.get('q')).toBe('sim studio') + expect(init).toMatchObject({ method: 'GET', redirect: 'error' }) + }) + + it('fails silently for short, oversized, rejected, and unsuccessful requests', async () => { + const fetcher = vi.fn( + async (_url: string, _init: RequestInit): Promise => + new Response(null, { status: 503 }) + ) + const service = new SearchSuggestionService(fetcher) + + await expect(service.suggest('s')).resolves.toEqual([]) + await expect(service.suggest('x'.repeat(201))).resolves.toEqual([]) + await expect(service.suggest('search me')).resolves.toEqual([]) + + fetcher.mockRejectedValueOnce(new Error('offline')) + await expect(service.suggest('another search')).resolves.toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/browser-search/suggestions.ts b/apps/desktop/src/main/browser-search/suggestions.ts new file mode 100644 index 00000000000..139588aaacb --- /dev/null +++ b/apps/desktop/src/main/browser-search/suggestions.ts @@ -0,0 +1,102 @@ +import { net } from 'electron' + +const GOOGLE_SUGGESTIONS_ENDPOINT = 'https://suggestqueries.google.com/complete/search' +const SEARCH_SUGGESTION_TIMEOUT_MS = 2_500 +const SEARCH_SUGGESTION_CACHE_TTL_MS = 5 * 60 * 1_000 +const MAX_SEARCH_SUGGESTION_CACHE_ENTRIES = 100 +const MAX_SEARCH_SUGGESTION_QUERY_LENGTH = 200 +const MAX_SEARCH_SUGGESTION_LENGTH = 256 +const MAX_SEARCH_SUGGESTIONS = 7 + +type SearchSuggestionFetch = (url: string, init: RequestInit) => Promise + +interface CachedSearchSuggestions { + expiresAt: number + values: string[] +} + +/** + * Validates the small portion of Google's Firefox-completion response that the + * omnibox consumes. Everything else in the provider payload is ignored. + */ +export function parseSearchSuggestionResponse(payload: unknown, query: string): string[] { + if (!Array.isArray(payload) || !Array.isArray(payload[1])) return [] + + const queryKey = query.toLocaleLowerCase() + const seen = new Set([queryKey]) + const suggestions: string[] = [] + for (const candidate of payload[1]) { + if (typeof candidate !== 'string') continue + const value = candidate.trim() + const key = value.toLocaleLowerCase() + if (!value || value.length > MAX_SEARCH_SUGGESTION_LENGTH || seen.has(key)) continue + seen.add(key) + suggestions.push(value) + if (suggestions.length === MAX_SEARCH_SUGGESTIONS) break + } + return suggestions +} + +/** + * In-memory, bounded search completion client. Queries go only to the fixed + * Google suggestions origin and are never logged or written to disk. + */ +export class SearchSuggestionService { + private readonly cache = new Map() + + constructor( + private readonly fetcher: SearchSuggestionFetch, + private readonly now: () => number = Date.now + ) {} + + async suggest(rawQuery: unknown): Promise { + if (typeof rawQuery !== 'string') return [] + const query = rawQuery.trim() + if (query.length < 2 || query.length > MAX_SEARCH_SUGGESTION_QUERY_LENGTH) return [] + + const cacheKey = query.toLocaleLowerCase() + const cached = this.cache.get(cacheKey) + if (cached && cached.expiresAt > this.now()) return [...cached.values] + if (cached) this.cache.delete(cacheKey) + + const url = new URL(GOOGLE_SUGGESTIONS_ENDPOINT) + url.searchParams.set('client', 'firefox') + url.searchParams.set('q', query) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), SEARCH_SUGGESTION_TIMEOUT_MS) + + try { + const response = await this.fetcher(url.toString(), { + method: 'GET', + redirect: 'error', + signal: controller.signal, + }) + if (!response.ok) return [] + const values = parseSearchSuggestionResponse(await response.json(), query) + this.remember(cacheKey, values) + return [...values] + } catch { + return [] + } finally { + clearTimeout(timeout) + } + } + + private remember(key: string, values: string[]): void { + if (this.cache.size >= MAX_SEARCH_SUGGESTION_CACHE_ENTRIES) { + const oldest = this.cache.keys().next().value + if (typeof oldest === 'string') this.cache.delete(oldest) + } + this.cache.set(key, { + expiresAt: this.now() + SEARCH_SUGGESTION_CACHE_TTL_MS, + values: [...values], + }) + } +} + +const searchSuggestionService = new SearchSuggestionService((url, init) => net.fetch(url, init)) + +/** Fetches live Google completions, failing closed to an empty local-only list. */ +export function getSearchSuggestions(query: unknown): Promise { + return searchSuggestionService.suggest(query) +} diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 3e0d10949a8..654fbacfa35 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -101,6 +101,8 @@ export interface DesktopSettings { launchAtLogin?: boolean autoDownloadUpdates?: boolean browserEnabled?: boolean + /** Whether omnibox typing may request live Google search completions. */ + browserSearchSuggestionsEnabled?: boolean terminalEnabled?: boolean /** Device-wide browser page appearance; `app` follows Sim. */ browserTheme?: 'app' | 'light' | 'dark' @@ -216,6 +218,7 @@ const DEFAULT_SETTINGS: DesktopSettings = { launchAtLogin: false, autoDownloadUpdates: true, browserEnabled: true, + browserSearchSuggestionsEnabled: true, terminalEnabled: true, } diff --git a/apps/desktop/src/main/desktop-settings.test.ts b/apps/desktop/src/main/desktop-settings.test.ts index b7849a57558..d2bcac944ac 100644 --- a/apps/desktop/src/main/desktop-settings.test.ts +++ b/apps/desktop/src/main/desktop-settings.test.ts @@ -117,6 +117,17 @@ describe('desktop settings service', () => { expect(setTerminalEnabled).toHaveBeenCalledWith(false) }) + it('defaults live browser search suggestions on and persists the privacy switch', () => { + const { config, service } = makeService() + + expect(service.getPreferences().browserSearchSuggestionsEnabled).toBe(true) + + const preferences = service.setBrowserSearchSuggestionsEnabled(false) + + expect(config.get('browserSearchSuggestionsEnabled')).toBe(false) + expect(preferences.browserSearchSuggestionsEnabled).toBe(false) + }) + it('persists browser and terminal appearance with match-Sim defaults', () => { const { config, service, setBrowserTheme, onBrowserThemeChanged } = makeService() expect(service.getPreferences()).toMatchObject({ diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index f84b364b40b..7f25296b99b 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -36,6 +36,7 @@ export function isDesktopPreferenceKey(value: unknown): value is DesktopPreferen export interface DesktopSettingsService { getPreferences(): DesktopPreferences setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences + setBrowserSearchSuggestionsEnabled(enabled: boolean): DesktopPreferences setAppearancePreference( key: DesktopAppearanceSettingKey, value: DesktopAppearanceTheme @@ -90,6 +91,7 @@ function readPreferences( autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true, trayEnabled: config.get('trayEnabled') ?? true, browserEnabled: config.get('browserEnabled') ?? true, + browserSearchSuggestionsEnabled: config.get('browserSearchSuggestionsEnabled') ?? true, terminalEnabled: config.get('terminalEnabled') ?? true, browserTheme: isDesktopAppearanceTheme(browserTheme) ? browserTheme : 'app', browserDefaultZoom: isDesktopZoomPercent(browserDefaultZoom) ? browserDefaultZoom : 100, @@ -153,6 +155,11 @@ export function createDesktopSettingsService( } return read() }, + setBrowserSearchSuggestionsEnabled(enabled) { + deps.config.set('browserSearchSuggestionsEnabled', enabled) + deps.config.flush() + return read() + }, setAppearancePreference(key, value) { const previousBrowserTheme = key === 'browserTheme' ? read().browserTheme : undefined deps.config.set(key, value) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index a23f83b4745..a74e43a9b56 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -18,6 +18,10 @@ vi.mock('@/main/browser-import', () => ({ })), })) +vi.mock('@/main/browser-search/suggestions', () => ({ + getSearchSuggestions: vi.fn(async () => ['sim ai workflow']), +})) + const { terminalThemeProfile } = vi.hoisted(() => ({ terminalThemeProfile: { id: 'iterm2:ocean', @@ -124,6 +128,7 @@ import { importChromePasswords, listChromeImportProfiles, } from '@/main/browser-import' +import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' @@ -252,6 +257,7 @@ describe('registerIpcHandlers', () => { vi.mocked(listChromeImportProfiles).mockClear() vi.mocked(importChromeCookies).mockClear() vi.mocked(importChromePasswords).mockClear() + vi.mocked(getSearchSuggestions).mockClear() vi.mocked(findCachedTerminalThemeProfile).mockClear() vi.mocked(listTerminalThemeProfiles).mockClear() vi.mocked(credentialsAvailable).mockClear() @@ -282,6 +288,7 @@ describe('registerIpcHandlers', () => { settings: { getPreferences: vi.fn(() => DEFAULT_DESKTOP_PREFERENCES), setPreference: vi.fn(), + setBrowserSearchSuggestionsEnabled: vi.fn(), setAppearancePreference: vi.fn(), setBrowserDefaultZoom: vi.fn(), setTerminalDefaultZoom: vi.fn(), @@ -325,6 +332,22 @@ describe('registerIpcHandlers', () => { expect(shell.openExternal).toHaveBeenCalledTimes(1) }) + it('keeps live search suggestions behind the app origin and privacy preference', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('browser-agent:search-suggestions') + + expect(await handler?.(evilEvent, 'sim ai')).toEqual([]) + + expect(await handler?.(appEvent, 'sim ai')).toEqual(['sim ai workflow']) + expect(getSearchSuggestions).toHaveBeenCalledWith('sim ai') + + vi.mocked(deps.settings.getPreferences).mockReturnValue({ + ...DEFAULT_DESKTOP_PREFERENCES, + browserSearchSuggestionsEnabled: false, + }) + expect(await handler?.(appEvent, 'sim ai')).toEqual([]) + }) + it('restricts the OAuth connect handoff to the app origin', async () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4b4b48bb7d8..23d89dcd23d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -70,6 +70,7 @@ import { importChromePasswords, listChromeImportProfiles, } from '@/main/browser-import' +import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { listSites } from '@/main/browser-sites' import { isSafeInternalPath } from '@/main/config' import type { DesktopSettingsService } from '@/main/desktop-settings' @@ -655,6 +656,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { ? deps.settings.setPreference(key, value) : deps.settings.getPreferences(), }, + 'desktop:settings:set-browser-search-suggestions': { + kind: 'invoke', + gate: 'app-origin', + denied: null, + handler: (enabled) => + typeof enabled === 'boolean' + ? deps.settings.setBrowserSearchSuggestionsEnabled(enabled) + : deps.settings.getPreferences(), + }, 'desktop:settings:set-appearance': { kind: 'invoke', gate: 'app-origin', @@ -922,6 +932,16 @@ export function registerIpcHandlers(deps: IpcDeps): void { denied: { sessions: [] }, handler: () => getKnownSessions(), }, + 'browser-agent:search-suggestions': { + kind: 'invoke', + gate: 'app-origin', + requires: 'browser', + denied: [], + handler: (query) => + deps.settings.getPreferences().browserSearchSuggestionsEnabled === false + ? [] + : getSearchSuggestions(query), + }, 'browser-agent:clear-browsing-data': { kind: 'invoke', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index dea8ac89579..42cab747db4 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -32,6 +32,8 @@ describe('desktop preload bridge', () => { await exposed.browserAgent.setPanelOccluded(true, 'chat-default') await exposed.browserAgent.setPanelOccluded(false, 'chat-explicit-false', false) await exposed.browserAgent.setPanelOccluded(true, 'chat-force', true) + await exposed.browserAgent.getSearchSuggestions?.('sim ai') + await exposed.settings.setBrowserSearchSuggestionsEnabled?.(false) expect(invoke.mock.calls).toEqual([ ['browser-agent:cancel-tool', 'tool-1', 'chat-default'], @@ -39,6 +41,8 @@ describe('desktop preload bridge', () => { ['browser-agent:set-panel-occluded', true, 'chat-default', false], ['browser-agent:set-panel-occluded', false, 'chat-explicit-false', false], ['browser-agent:set-panel-occluded', true, 'chat-force', true], + ['browser-agent:search-suggestions', 'sim ai'], + ['desktop:settings:set-browser-search-suggestions', false], ]) }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index ffb5314df73..afe84f2a42e 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -147,6 +147,8 @@ const api: SimDesktopApi = { getPreferences: (): Promise => ipcRenderer.invoke('desktop:settings:get'), setPreference: (key: DesktopPreferenceKey, value: boolean): Promise => ipcRenderer.invoke('desktop:settings:set', key, value), + setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('desktop:settings:set-browser-search-suggestions', enabled), notify: (payload: DesktopNotificationPayload): Promise => ipcRenderer.invoke('desktop:settings:notify', payload), setBrowserTheme: (theme: DesktopAppearanceTheme): Promise => @@ -281,6 +283,8 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-agent:get-tabs-state', scopeId), getKnownSessions: (): Promise => ipcRenderer.invoke('browser-agent:get-known-sessions'), + getSearchSuggestions: (query: string): Promise => + ipcRenderer.invoke('browser-agent:search-suggestions', query), clearBrowsingData: (kinds?: readonly BrowserDataKind[]): Promise => ipcRenderer.invoke('browser-agent:clear-browsing-data', kinds), getDownloadsState: (scopeId: string): Promise => diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index 0edd0e9f3f0..957dab4d10d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -190,6 +190,10 @@ describe('initialUrlSuggestionIndex', () => { expect(initialUrlSuggestionIndex('https://sim.ai', 3)).toBeNull() }) + it('selects the exact search row after typing on an existing page', () => { + expect(initialUrlSuggestionIndex('https://sim.ai', 3, 'what is the best')).toBe(0) + }) + it('selects nothing when there are no suggestions', () => { expect(initialUrlSuggestionIndex('', 0)).toBeNull() }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 3d241ca738b..2f4ba98caf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -39,6 +39,7 @@ import { onFocusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-short import { fillBrowserCredential, loadBrowserFillOptions, + loadBrowserSearchSuggestions, loadBrowserSuggestionSources, onBrowserAddToChat, onBrowserAppearanceThemeChanged, @@ -84,12 +85,16 @@ import { import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip' import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice' import { + buildOmniboxSuggestions, + googleSearchUrl, + isSearchQueryInput, mergeSuggestionSources, moveActiveIndex, - rankSuggestions, + type OmniboxSuggestion, type UrlSuggestion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions' import { ResourceZoomMenuItems } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/resource-zoom-menu-items' +import { useDebounce } from '@/hooks/use-debounce' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { MOTHERSHIP_WIDTH } from '@/stores/constants' @@ -97,6 +102,7 @@ import type { ChatContext } from '@/stores/panel' /** Ties the omnibox to its listbox for assistive tech. */ const SUGGESTIONS_LIST_ID = 'browser-url-suggestions' +const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160 const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000 const EMPTY_BROWSER_TABS: BrowserTabState[] = [] @@ -156,14 +162,10 @@ export function browserSelectionContext({ export function resolveUrlBarInput(raw: string): string { const input = raw.trim() if (/^https?:\/\//i.test(input)) return input - const hostLike = - /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i - if (!input.includes(' ') && hostLike.test(input)) { - const isLocal = - /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input) - return `${isLocal ? 'http' : 'https'}://${input}` - } - return `https://www.google.com/search?q=${encodeURIComponent(input)}` + if (isSearchQueryInput(input)) return googleSearchUrl(input) + const isLocal = + /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|0\.0\.0\.0|\[::1?\])(:\d+)?([/?#]|$)/i.test(input) + return `${isLocal ? 'http' : 'https'}://${input}` } /** @@ -302,13 +304,14 @@ export function shouldOpenUrlSuggestions( return activeOverlay === 'suggestions' && suggestionCount > 0 } -/** New tabs submit the best suggestion; existing pages submit their current URL. */ +/** Typed searches and new tabs select the first row; an untouched page URL remains literal. */ export function initialUrlSuggestionIndex( pageUrl: string | undefined, - suggestionCount: number + suggestionCount: number, + query = '' ): number | null { if (suggestionCount === 0) return null - return !pageUrl || pageUrl === 'about:blank' ? 0 : null + return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null } /** A new-tab request is complete only after the authoritative strip grows and activates a new id. */ @@ -429,6 +432,15 @@ export function BrowserSession({ const [suggestionsVisible, setSuggestionsVisible] = useState(false) /** Empty on initial focus; follows the typed text once the user edits it. */ const [suggestionQuery, setSuggestionQuery] = useState(null) + /** Live completions tagged with the query that produced them, so late replies cannot leak in. */ + const [searchCompletions, setSearchCompletions] = useState<{ + query: string + values: string[] + }>({ query: '', values: [] }) + const debouncedSuggestionQuery = useDebounce( + suggestionQuery ?? '', + SEARCH_SUGGESTIONS_DEBOUNCE_MS + ) /** Whether the find bar is docked above the page. */ const [findOpen, setFindOpen] = useState(false) const { @@ -502,6 +514,23 @@ export function BrowserSession({ } }, [panelVisible]) + /** Debounced live completions never block the immediate local/search row. */ + useEffect(() => { + const query = debouncedSuggestionQuery.trim() + if (!suggestionsVisible || !isSearchQueryInput(query)) { + setSearchCompletions({ query: '', values: [] }) + return + } + + let active = true + void loadBrowserSearchSuggestions(query).then((values) => { + if (active) setSearchCompletions({ query, values }) + }) + return () => { + active = false + } + }, [debouncedSuggestionQuery, suggestionsVisible]) + useEffect(() => { if (appearanceTheme) { const next = resolveDesktopAppearanceTheme(appearanceTheme, theme) @@ -833,17 +862,18 @@ export function BrowserSession({ * Programmatic focus on a new tab keeps the omnibox ready for typing without * opening this list. A pointer interaction or typed edit opts into suggestions. */ - const suggestions = useMemo( - () => - suggestionsVisible && suggestionQuery !== null - ? rankSuggestions(suggestionCorpus, suggestionQuery) - : [], - [suggestionCorpus, suggestionQuery, suggestionsVisible] - ) + const suggestions = useMemo((): OmniboxSuggestion[] => { + if (!suggestionsVisible || suggestionQuery === null) return [] + const query = suggestionQuery.trim() + const live = searchCompletions.query === query ? searchCompletions.values : [] + return buildOmniboxSuggestions(suggestionCorpus, suggestionQuery, live) + }, [searchCompletions, suggestionCorpus, suggestionQuery, suggestionsVisible]) useEffect(() => { - setActiveSuggestion(initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length)) - }, [suggestionOriginUrl, suggestions]) + setActiveSuggestion( + initialUrlSuggestionIndex(suggestionOriginUrl, suggestions.length, suggestionQuery ?? '') + ) + }, [suggestionOriginUrl, suggestionQuery, suggestions]) // The suggestion list is renderer UI that extends over the native page. // Keep the page's exact captured frame underneath it while it is open so @@ -1075,6 +1105,7 @@ export function BrowserSession({ placeholder='Search Google or enter a URL' autoComplete='off' role='combobox' + aria-autocomplete='list' aria-expanded={suggestionsOpen} aria-controls={SUGGESTIONS_LIST_ID} aria-activedescendant={ @@ -1092,6 +1123,7 @@ export function BrowserSession({ onChange={(event) => { setSuggestionsVisible(true) setSuggestionQuery(event.target.value) + setSearchCompletions({ query: '', values: [] }) setUrlDraft(event.target.value) // The old highlight pointed at a row that may no longer be // in the list, let alone in the same position. @@ -1150,7 +1182,11 @@ export function BrowserSession({ > {suggestions.map((suggestion, index) => ( navigateTo(suggestion.url)} >
- - {suggestion.name ? ( + {suggestion.kind === 'search' ? ( + <> + + {suggestion.query} + + ) : ( + + )} + {suggestion.kind === 'site' && suggestion.name ? (
{suggestion.name} — {suggestion.hostname}
- ) : ( + ) : suggestion.kind === 'site' ? ( {suggestion.hostname} - )} + ) : null}
))} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts index f4407ab3f6a..978d67badf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.test.ts @@ -2,6 +2,9 @@ import type { BrowserKnownSession, BrowserSessionEvidence } from '@sim/browser-p import type { BrowserCredentialMetadata, BrowserSiteInfo } from '@sim/desktop-bridge' import { describe, expect, it } from 'vitest' import { + buildOmniboxSuggestions, + googleSearchUrl, + isSearchQueryInput, mergeSuggestionSources, moveActiveIndex, rankSuggestions, @@ -421,6 +424,65 @@ describe('rankSuggestions', () => { }) }) +describe('buildOmniboxSuggestions', () => { + it('leads with the exact search, keeps matching sites, then adds live completions', () => { + const results = buildOmniboxSuggestions( + [suggestion('mail.google.com', 100, 'Gmail')], + 'gmail', + ['gmail login', 'gmail account'] + ) + + expect(results.map((result) => [result.kind, result.url])).toEqual([ + ['search', googleSearchUrl('gmail')], + ['site', 'https://mail.google.com'], + ['search', googleSearchUrl('gmail login')], + ['search', googleSearchUrl('gmail account')], + ]) + }) + + it('does not send URL-looking input through search completions', () => { + const results = buildOmniboxSuggestions([suggestion('github.com', 100)], 'github.com', [ + 'github.com login', + ]) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' }) + }) + + it('deduplicates completions and caps the combined dropdown', () => { + const results = buildOmniboxSuggestions( + [], + 'sim ai', + ['sim ai', 'SIM AI', 'sim ai workflow', 'sim ai agents'], + 2 + ) + + expect(results.map((result) => result.kind === 'search' && result.query)).toEqual([ + 'sim ai', + 'sim ai workflow', + ]) + }) + + it('keeps an empty omnibox local-only', () => { + const results = buildOmniboxSuggestions([suggestion('github.com', 100)], '', [ + 'ignored remote completion', + ]) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ kind: 'site', hostname: 'github.com' }) + }) +}) + +describe('isSearchQueryInput', () => { + it('distinguishes searches from navigable addresses', () => { + expect(isSearchQueryInput('what is the best browser')).toBe(true) + expect(isSearchQueryInput('electron')).toBe(true) + expect(isSearchQueryInput('sim.ai/docs')).toBe(false) + expect(isSearchQueryInput('https://sim.ai')).toBe(false) + expect(isSearchQueryInput('localhost:3000')).toBe(false) + }) +}) + describe('moveActiveIndex', () => { it('highlights nothing until the user arrows in, so Enter still means "go to what I typed"', () => { expect(moveActiveIndex(null, 1, 3)).toBe(0) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts index c5ae7399c7f..4ab226a38ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/url-suggestions.ts @@ -47,6 +47,25 @@ export interface UrlSuggestion { visits?: number } +export type OmniboxSuggestion = + | ({ kind: 'site' } & UrlSuggestion) + | { kind: 'search'; query: string; url: string } + +const HOST_LIKE_INPUT = + /^([a-z0-9-]+(\.[a-z0-9-]+)+|localhost|\d{1,3}(\.\d{1,3}){3}|\[[0-9a-f:]+\])(:\d+)?([/?#].*)?$/i + +/** Whether an omnibox value should search rather than navigate directly. */ +export function isSearchQueryInput(raw: string): boolean { + const input = raw.trim() + if (!input || /^https?:\/\//i.test(input)) return false + return input.includes(' ') || !HOST_LIKE_INPUT.test(input) +} + +/** The canonical Google results URL used by search rows and bare submission. */ +export function googleSearchUrl(query: string): string { + return `https://www.google.com/search?q=${encodeURIComponent(query.trim())}` +} + function timestamp(value: string | undefined): number { if (!value) return 0 const parsed = Date.parse(value) @@ -189,6 +208,42 @@ export function rankSuggestions( return scored.slice(0, limit).map((entry) => entry.suggestion) } +/** + * Combines immediate navigation/search actions with the user's known sites and + * live completions. The exact typed search leads, known sites retain priority, + * and remote completions fill whatever room remains. + */ +export function buildOmniboxSuggestions( + siteCorpus: readonly UrlSuggestion[], + rawQuery: string, + searchCompletions: readonly string[] = [], + limit: number = MAX_URL_SUGGESTIONS +): OmniboxSuggestion[] { + if (limit <= 0) return [] + const query = rawQuery.trim() + const sites = rankSuggestions(siteCorpus, query, limit) + if (!query || !isSearchQueryInput(query)) { + return sites.map((site) => ({ ...site, kind: 'site' })) + } + + const results: OmniboxSuggestion[] = [{ kind: 'search', query, url: googleSearchUrl(query) }] + for (const site of sites) { + if (results.length === limit) return results + results.push({ ...site, kind: 'site' }) + } + + const seen = new Set([query.toLocaleLowerCase()]) + for (const candidate of searchCompletions) { + const completion = candidate.trim() + const key = completion.toLocaleLowerCase() + if (!completion || seen.has(key)) continue + seen.add(key) + results.push({ kind: 'search', query: completion, url: googleSearchUrl(completion) }) + if (results.length === limit) break + } + return results +} + /** * How well the browser knows a host, then how much it is used, then how * recently, then alphabetically so the same corpus always comes back in the diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index d907369426b..b4333837c48 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -243,9 +243,9 @@ const ResourceTabItem = memo(function ResourceTabItem({ > {config.renderTabIcon(resource, 'mr-1.5 size-[14px]')} {displayName} - {hasActivity && !isActive && ( + {hasActivity && !isActive && !isHovered && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index dd5d02da37d..eaefecea249 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -55,7 +55,13 @@ import { UserInput, type UserInputHandle, } from './components' -import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' +import { + getMothershipUseChatOptions, + type ResourceEventOptions, + shouldActivateResourceEvent, + useChat, + useMothershipResize, +} from './hooks' import type { FileAttachmentForApi, MothershipResource, @@ -211,14 +217,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const activeResourceParamRef = useRef(activeResourceParam) activeResourceParamRef.current = activeResourceParam - function handleResourceEvent(resourceId: string) { - // Agent work should always make the resource surface available, but it - // must never replace an existing selection. Activity in another resource - // stays in the background and gets an attention marker instead. + function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { + // Agent work makes the resource surface available without replacing an + // existing selection. Explicit user navigation can request activation. if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) const activeResourceId = activeResourceParamRef.current - if (activeResourceId && activeResourceId !== resourceId) { + if (!shouldActivateResourceEvent(activeResourceId, resourceId, options)) { setResourceActivityIds((current) => new Set(current).add(resourceId)) return } @@ -228,7 +233,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) next.delete(resourceId) return next }) - if (activeResourceId !== resourceId) setActiveResourceUrl(resourceId) + if (activeResourceId !== resourceId) { + activeResourceParamRef.current = resourceId + setActiveResourceUrl(resourceId) + } } const { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index 995df519868..8c1fa13edd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -1,6 +1,8 @@ +export type { ResourceEventOptions } from './use-chat' export { getMothershipUseChatOptions, getWorkflowCopilotUseChatOptions, + shouldActivateResourceEvent, useChat, } from './use-chat' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index 281714e81d9..fa4d09e96b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -13,6 +13,7 @@ import { panelForExecutingClientTool, reconcileLiveAssistantTurn, selectReconnectReplayState, + shouldActivateResourceEvent, waitForDetachedChatResolution, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { @@ -30,6 +31,20 @@ vi.mock('next/navigation', () => ({ }), })) +describe('shouldActivateResourceEvent', () => { + it('keeps background agent activity from replacing another selected resource', () => { + expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) + }) + + it('allows an explicit user action to replace another selected resource', () => { + expect( + shouldActivateResourceEvent('file-1', 'browser-session', { + activate: true, + }) + ).toBe(true) + }) +}) + function userMessage(id: string): PersistedMessage { return { id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index acbea0bd61f..926e87362e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -30,7 +30,7 @@ import { onOpenInBrowserPanel } from '@/lib/browser-agent/open-in-panel' import { cancelActiveBrowserTools, initBrowserAgentTransport, - sendBrowserPanelAction, + openUrlInNewBrowserTab, } from '@/lib/browser-agent/transport' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { toDisplayMessage } from '@/lib/copilot/chat/display-message' @@ -1188,8 +1188,22 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } +export interface ResourceEventOptions { + activate?: boolean +} + +export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void + +export function shouldActivateResourceEvent( + activeResourceId: string | null, + resourceId: string, + options?: ResourceEventOptions +): boolean { + return options?.activate === true || !activeResourceId || activeResourceId === resourceId +} + export interface UseChatOptions { - onResourceEvent?: (resourceId: string) => void + onResourceEvent?: ResourceEventHandler apiPath?: string stopPath?: string workflowId?: string @@ -1923,14 +1937,20 @@ export function useChat( [workspaceId] ) - const openBrowserResource = useCallback(() => { - addResource({ - type: 'browser', - id: BROWSER_SESSION_RESOURCE_ID, - title: 'Browser', - }) - onResourceEventRef.current?.(BROWSER_SESSION_RESOURCE_ID) - }, [addResource]) + const openBrowserResource = useCallback( + (activate = false) => { + addResource({ + type: 'browser', + id: BROWSER_SESSION_RESOURCE_ID, + title: 'Browser', + }) + onResourceEventRef.current?.( + BROWSER_SESSION_RESOURCE_ID, + activate ? { activate: true } : undefined + ) + }, + [addResource] + ) const getResourceActivityTracker = useCallback( (generation: number, targetChatId?: string) => { @@ -2046,8 +2066,12 @@ export function useChat( // (message components dispatch the request; this hook owns the resource). useEffect(() => { return onOpenInBrowserPanel((url) => { - openBrowserResource() - sendBrowserPanelAction('navigate', { url }, desktopScopeIdRef.current) + openBrowserResource(true) + void openUrlInNewBrowserTab(url, desktopScopeIdRef.current).catch((error) => { + logger.warn('Failed to open chat link in a new browser tab', { + error: getErrorMessage(error), + }) + }) }) }, [openBrowserResource]) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx index 327771b3a5b..bb3e1a1109e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/browser/browser.test.tsx @@ -87,8 +87,22 @@ vi.mock('@sim/emcn', () => ({ ), Label: ({ children }: { children: ReactNode }) => {children}, - Switch: ({ checked }: { checked: boolean }) => ( - ' + const button = visible(document.querySelector('button') as HTMLButtonElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Send') + let clicked = false + button.addEventListener('click', () => { + clicked = true + }) + + window.history.pushState({}, '', '/client/T123/C456') + + expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: false }) + expect(clicked).toBe(true) + }) + + it('recovers a replaced ref after a same-document URL change', () => { + document.body.innerHTML = '' + const original = visible(document.querySelector('button') as HTMLButtonElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Messages') + const replacement = visible(original.cloneNode(true) as HTMLButtonElement) + let clicked = false + replacement.addEventListener('click', () => { + clicked = true + }) + + window.history.pushState({}, '', '/client/T123/C456') + original.replaceWith(replacement) + + expect(clickElement(ref)).toMatchObject({ dispatched: true, refRecovered: true }) + expect(clicked).toBe(true) + }) + it('refuses to recover a ref when replacement is ambiguous', () => { document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index f5f4cb2f9a3..3bdb88a64c0 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -109,6 +109,7 @@ export function collectSnapshot(startingElementId = 0): unknown { '[onclick]', '[contenteditable="true"]', '[contenteditable=""]', + '[contenteditable="plaintext-only"]', ].join(', ') const landmarkSelector = [ 'nav', @@ -593,7 +594,7 @@ export function collectSnapshot(startingElementId = 0): unknown { /** * React commonly replaces a control's DOM node while preserving its - * semantics. Recover only when the old page URL and a strong semantic + * semantics. Recover only when the old page origin and a strong semantic * fingerprint still identify one candidate; a weak or ambiguous match is a * real stale ref, never permission to click something nearby. */ @@ -601,6 +602,19 @@ export function collectSnapshot(startingElementId = 0): unknown { const locator = locators[id] if (!locator) return null + // Origin, not full URL: a live SPA rewrites its path with pushState + // between snapshot and act (Slack does so continuously), while the + // element the model chose is often still the same mounted node. The + // origin still pins the document/frame; the role/name/attribute and + // ancestor/context signatures below pin the element itself. + const pageOriginOf = (url: string): string => { + try { + return new URL(url).origin + } catch { + return url + } + } + const stableAttributes = [ 'id', 'href', @@ -620,7 +634,7 @@ export function collectSnapshot(startingElementId = 0): unknown { const identityMatches = (candidate: Element, connected = false): boolean => { if ( candidate.tagName.toUpperCase() !== locator.tag || - pageUrlFor(candidate) !== locator.url || + pageOriginOf(pageUrlFor(candidate)) !== pageOriginOf(locator.url) || roleFor(candidate) !== locator.role ) { return false @@ -1028,7 +1042,7 @@ export function clickElement( addCandidate(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addCandidate(candidate) @@ -1244,7 +1258,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { addEditable(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addEditable(candidate) @@ -1770,7 +1784,7 @@ export function typeIntoElement(id: number, text: string, submit: boolean): unkn addEditable(el) for (const candidate of Array.from( el.querySelectorAll( - 'input, textarea, [contenteditable="true"], [contenteditable=""]' + 'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]' ) )) { addEditable(candidate) From f54f7e003f52144be325f5a8dc40c108c4e4487f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:06:40 -0700 Subject: [PATCH 013/135] Harden workflow sanitization and Slack setup --- .../connect-slack-bot-modal.tsx | 12 ++--- .../workflow/edit-workflow/validation.ts | 17 ++++++- apps/sim/lib/copilot/vfs/serializers.ts | 45 +++++++++++++++++++ .../sanitization/json-sanitizer.test.ts | 44 +++++++++++++++++- .../workflows/sanitization/json-sanitizer.ts | 37 ++++++++++++++- apps/sim/triggers/constants.ts | 10 +++++ apps/sim/triggers/webhook-url.ts | 10 +++++ 7 files changed, 163 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index ba923cc1ffd..31989db2375 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -17,13 +17,13 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { SlackIcon } from '@/components/icons' -import { getBaseUrl } from '@/lib/core/utils/urls' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { useCreateWorkspaceCredential, useUpdateWorkspaceCredential, } from '@/hooks/queries/credentials' import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' const logger = createLogger('ConnectSlackBotModal') @@ -109,13 +109,9 @@ export function ConnectSlackBotModal({ } }, [open, created, isReconnect, initialDisplayName, initialDescription]) - // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be - // able to reach this URL, so it has to be the app's public base (e.g. the - // tunnel host in dev), not whatever host the browser happens to be on. - const requestUrl = useMemo( - () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`, - [credentialId] - ) + // Shared server-side derivation: uses the app public base (not + // window.location.origin) so Slack's servers can reach it. + const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId]) const manifestJson = useMemo(() => { const manifest = buildSlackManifest(selected, { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 2780a902496..d53f5ddc203 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -22,7 +22,11 @@ import { BlockType, EDGE, normalizeName } from '@/executor/constants' import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' import { isPiByokOnlyMode } from '@/providers/pi-providers' import { getTool } from '@/tools/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { + TRIGGER_ROUTING_FIELD, + TRIGGER_RUNTIME_SUBBLOCK_IDS, + TRIGGER_WEBHOOK_URL_FIELD, +} from '@/triggers/constants' import type { EdgeHandleValidationResult, EditWorkflowOperation, @@ -75,6 +79,17 @@ export function validateInputsForBlock( inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD]) } + if (TRIGGER_ROUTING_FIELD in inputs) { + errors.push({ + blockId, + blockType, + field: TRIGGER_ROUTING_FIELD, + value: inputs[TRIGGER_ROUTING_FIELD], + error: `"${TRIGGER_ROUTING_FIELD}" is read-only. Event routing is derived from the selected credential and cannot be edited on the block.`, + }) + inputs = omit(inputs, [TRIGGER_ROUTING_FIELD]) + } + const blockConfig = getBlock(blockType) if (!blockConfig) { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index cb5348a061d..5ad229b53dd 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -12,6 +12,7 @@ import { SANDBOX_SELECTABLE_CLI_TOOL_IDS, } from '@/lib/execution/remote-sandbox/cli-tools' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -24,6 +25,8 @@ import { SIM_AUTO_MODEL_ID, } from '@/providers/models' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' +import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' /** The service-account alternative to OAuth for a service, when it offers one. */ export interface VfsServiceAccountAuth { @@ -733,6 +736,15 @@ export function serializeCredentials( // credential) — they reconnect differently, so the agent must branch on // this. Env-var credentials carry no type. type: a.credentialType, + // Derived, not stored: the public Request URL a Slack custom-bot app + // posts events to. One per credential; every workflow trigger that + // selects this credential shares it. This is what the setup wizard shows + // in Slack's Event Subscriptions step. + ...(a.credentialType === 'service_account' && + a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && + a.id + ? { requestUrl: buildSlackCustomBotRequestUrl(a.id) } + : {}), connectedAt: a.createdAt.toISOString(), })), null, @@ -1137,6 +1149,38 @@ export function serializeIntegrationSchema( ) } +/** + * Derived setup reference for `slack_oauth` — the same material the custom-bot + * setup wizard shows, surfaced so the copilot can walk a user (or the browser + * agent) through Slack app creation without guessing. None of this is a block + * field: the manifest is a template for api.slack.com, and the Request URL is a + * per-credential property (`requestUrl` in environment/credentials.json). + */ +function slackOAuthSetupReference(): Record { + const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id) + return { + note: + 'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' + + 'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' + + 'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' + + 'and bot token without them entering the chat. Existing custom bots appear as service_account ' + + 'credentials in environment/credentials.json, each with its requestUrl.', + requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}', + capabilities: SLACK_CAPABILITIES.map((c) => ({ + id: c.id, + label: c.label, + group: c.group, + defaultChecked: c.defaultChecked, + scopes: c.scopes, + events: c.events, + })), + defaultManifest: buildSlackManifest(new Set(defaults), { + appName: 'Sim Bot', + webhookUrl: '', + }), + } +} + /** * Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json */ @@ -1160,6 +1204,7 @@ export function serializeTriggerSchema(trigger: { webhook: trigger.webhook || undefined, subBlocks: trigger.subBlocks.map(serializeSubBlock), outputs: trigger.outputs, + ...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}), }, null, 2 diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 4ac13e4a496..2e8ff1b59d6 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -5,7 +5,7 @@ import { resetUrlsMock, urlsMockFns } from '@sim/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' beforeAll(() => { urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test') @@ -282,3 +282,45 @@ describe('sanitizeForCopilot webhook trigger URL', () => { expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) }) }) + +describe('sanitizeForCopilot credential-routed trigger routing', () => { + it('synthesizes the read-only routing note for a slack_v2 block in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Trigger', + enabled: true, + triggerMode: true, + subBlocks: { + selectedTriggerId: { id: 'selectedTriggerId', type: 'short-input', value: 'slack_oauth' }, + customBotCredential: { + id: 'customBotCredential', + type: 'oauth-input', + value: 'cred-123', + }, + }, + }) + ) + + const routing = result.blocks['slack-1'].inputs?.[TRIGGER_ROUTING_FIELD] as + | Record + | undefined + expect(routing?.model).toBe('credential-routed') + expect(routing?.selectedCredentialId).toBe('cred-123') + expect(String(routing?.note)).toContain('no per-workflow webhook URL') + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) + }) + + it('omits the routing note when the block is not in trigger mode', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('slack-1', { + type: 'slack_v2', + name: 'Slack Action', + enabled: true, + subBlocks: {}, + }) + ) + + expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_ROUTING_FIELD) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 5a63706a447..bd4f2377b12 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -12,8 +12,8 @@ import type { WorkflowState, } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' -import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url' +import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { blockAdvertisesWebhookUrl, resolveBlockTriggerId } from '@/triggers/webhook-url' /** * Sanitized workflow state for copilot (removes all UI-specific data) @@ -364,6 +364,35 @@ function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | } } +/** Trigger ids that deliver by credential routing — no per-workflow URL exists. */ +const CREDENTIAL_ROUTED_TRIGGER_IDS = new Set(['slack_oauth']) + +/** + * Derived routing note for trigger blocks that have NO per-workflow webhook URL + * (credential-routed delivery, e.g. Slack v2's `slack_oauth`). Mirrors what the + * setup wizard shows: events arrive at the selected credential's endpoint — a + * custom bot's per-credential Request URL (surfaced as `requestUrl` on that + * credential in environment/credentials.json) or the shared Sim-app endpoint + * routed by Slack workspace. Surfaced as the read-only + * {@link TRIGGER_ROUTING_FIELD} input; rejected on write by `edit_workflow`. + */ +function resolveTriggerRouting(block: BlockState): Record | null { + const triggerId = resolveBlockTriggerId(block) + if (!triggerId || !CREDENTIAL_ROUTED_TRIGGER_IDS.has(triggerId)) return null + const selected = + block.subBlocks?.customBotCredential?.value ?? block.subBlocks?.manualBotCredential?.value + const selectedCredentialId = typeof selected === 'string' && selected.length > 0 ? selected : null + return { + model: 'credential-routed', + note: + 'This trigger has no per-workflow webhook URL. Events are delivered via the selected Slack credential: ' + + 'a custom bot posts to its per-credential Request URL (the requestUrl field on that credential in ' + + 'environment/credentials.json — the same URL the setup wizard shows for Slack Event Subscriptions); ' + + 'a Sim-app connection routes by Slack workspace automatically. Derived at read time; not an editable field.', + ...(selectedCredentialId ? { selectedCredentialId } : {}), + } +} + /** * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) * Uses 0-indexed numbering for else-if conditions @@ -587,6 +616,10 @@ export function sanitizeForCopilot( if (webhookUrl) { inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl } + const triggerRouting = resolveTriggerRouting(block) + if (triggerRouting) { + inputs[TRIGGER_ROUTING_FIELD] = triggerRouting + } } // Check if this is a loop or parallel (has children) diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index 64ed9a898ac..94f2cf9c382 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -40,6 +40,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ */ export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl' +/** + * Derived, read-only input surfaced on copilot reads of trigger blocks that + * route by CREDENTIAL rather than a per-workflow webhook URL (e.g. Slack v2's + * `slack_oauth`). Explains where events actually arrive — a custom bot's + * per-credential Request URL or the shared Sim-app endpoint — so the copilot + * can answer "where do I point Slack?" without inventing a field. Never + * stored; rejected on write like {@link TRIGGER_WEBHOOK_URL_FIELD}. + */ +export const TRIGGER_ROUTING_FIELD = 'triggerRouting' + /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. * This prevents runaway errors from continuously executing failing workflows. diff --git a/apps/sim/triggers/webhook-url.ts b/apps/sim/triggers/webhook-url.ts index 289e928a9cf..6f28289f084 100644 --- a/apps/sim/triggers/webhook-url.ts +++ b/apps/sim/triggers/webhook-url.ts @@ -12,6 +12,16 @@ export function buildWebhookTriggerUrl(path: string): string { return `${getBaseUrl()}/api/webhooks/trigger/${path}` } +/** + * The Request URL a Slack custom-bot app posts events to. One URL per + * credential (not per workflow): the endpoint verifies with the credential's + * signing secret and fans out to every workflow whose trigger routes by this + * credential id. Uses the app's public base so Slack's servers can reach it. + */ +export function buildSlackCustomBotRequestUrl(credentialId: string): string { + return `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}` +} + function subBlockValue(block: BlockState, subBlockId: string): unknown { return block.subBlocks?.[subBlockId]?.value } From 6f36331b7a979efb7c3db09a65a92829e290c63a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:25:32 -0700 Subject: [PATCH 014/135] feat(desktop): coordinate clicks, caret insertion, and drag for the browser agent --- apps/desktop/src/main/browser-agent/cdp.ts | 192 +++++++- .../src/main/browser-agent/driver.test.ts | 209 ++++++++ apps/desktop/src/main/browser-agent/driver.ts | 298 +++++++++++- .../main/browser-agent/page-functions.test.ts | 81 ++++ .../src/main/browser-agent/page-functions.ts | 152 ++++++ .../lib/copilot/generated/tool-catalog-v1.ts | 372 ++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 455 ++++++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 5 + packages/browser-protocol/src/index.ts | 3 + 9 files changed, 1743 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/cdp.ts b/apps/desktop/src/main/browser-agent/cdp.ts index 4a7088e1b24..6387461e803 100644 --- a/apps/desktop/src/main/browser-agent/cdp.ts +++ b/apps/desktop/src/main/browser-agent/cdp.ts @@ -10,6 +10,7 @@ */ import type { BrowserTheme } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' import type { WebContents, WebFrameMain } from 'electron' const logger = createLogger('BrowserAgentCdp') @@ -127,12 +128,29 @@ export async function setColorScheme(contents: WebContents, theme: BrowserTheme) }) } +/** Live drag-interception state while a dragPointer call is in flight. */ +interface DragInterception { + intercepted: boolean + data: Record | null +} +const dragInterceptionsByContents = new WeakMap() + function handleDebuggerEvent( contents: WebContents, method: string, params: Record, parentSessionId?: string ): void { + if (method === 'Input.dragIntercepted') { + const interception = dragInterceptionsByContents.get(contents) + if (interception) { + interception.intercepted = true + const data = params.data + interception.data = + data && typeof data === 'object' ? (data as Record) : null + } + return + } if (method === 'Target.attachedToTarget') { const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '' const targetInfo = params.targetInfo @@ -367,7 +385,9 @@ interface CdpViewport { * edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture * when layout metrics are unavailable. */ -export async function captureScreenshot(contents: WebContents): Promise { +export async function captureScreenshot( + contents: WebContents +): Promise<{ dataUrl: string; scale: number }> { const metrics = await send<{ cssLayoutViewport?: CdpViewport layoutViewport?: CdpViewport @@ -376,6 +396,8 @@ export async function captureScreenshot(contents: WebContents): Promise const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport const width = viewport?.clientWidth ?? 0 const height = viewport?.clientHeight ?? 0 + const scale = + width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1 const clip = width > 0 && height > 0 ? { @@ -383,7 +405,7 @@ export async function captureScreenshot(contents: WebContents): Promise y: 0, width, height, - scale: Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)), + scale, } : undefined @@ -392,7 +414,7 @@ export async function captureScreenshot(contents: WebContents): Promise quality: SCREENSHOT_QUALITY, ...(clip ? { clip } : {}), }) - return `data:image/jpeg;base64,${result.data}` + return { dataUrl: `data:image/jpeg;base64,${result.data}`, scale } } /** One half of a trusted key press (`Input.dispatchKeyEvent` params). */ @@ -430,7 +452,8 @@ export async function clickAt( contents: WebContents, x: number, y: number, - moveBeforePress = true + moveBeforePress = true, + clickCount = 1 ): Promise { if (moveBeforePress) await moveMouse(contents, x, y) let pressed = false @@ -439,22 +462,26 @@ export async function clickAt( // response (navigation/process swap). In that ambiguous case a release is // safer than leaving Blink's pointer state stuck down. pressed = true - await sendInput(contents, 'Input.dispatchMouseEvent', { - type: 'mousePressed', - x, - y, - button: 'left', - buttons: 1, - clickCount: 1, - }) - await sendInput(contents, 'Input.dispatchMouseEvent', { - type: 'mouseReleased', - x, - y, - button: 'left', - buttons: 0, - clickCount: 1, - }) + // A multi-click is a sequence of press/release pairs with an increasing + // clickCount — Blink synthesizes dblclick from the pair whose count is 2. + for (let count = 1; count <= clickCount; count++) { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x, + y, + button: 'left', + buttons: 1, + clickCount: count, + }) + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x, + y, + button: 'left', + buttons: 0, + clickCount: count, + }) + } pressed = false } finally { if (pressed && !contents.isDestroyed()) { @@ -473,6 +500,131 @@ export async function clickAt( } } +/** + * Drags the pointer from one viewport point to another through the trusted + * pipeline: press, a threshold-crossing nudge, interpolated moves with the + * button held, a settle hold over the target, then release or drop. + * + * Two drag models are covered by the one call. Pointer-sensor libraries + * (dnd-kit, react-beautiful-dnd, canvas apps) treat the held-button move + * sequence exactly like a human drag. Native HTML5 `draggable="true"` + * sources instead START a Blink drag session on the press+move — with + * `Input.setInterceptDrags` enabled, Chromium reports it as + * `Input.dragIntercepted` and the remaining movement is delivered as trusted + * `Input.dispatchDragEvent` dragEnter/dragOver events ending in a `drop` + * (the technique Playwright uses). Both paths are trusted input. + */ +export async function dragPointer( + contents: WebContents, + from: { x: number; y: number }, + to: { x: number; y: number }, + steps = 12, + stepDelayMs = 20 +): Promise<{ nativeDragIntercepted: boolean }> { + const interception: DragInterception = { intercepted: false, data: null } + dragInterceptionsByContents.set(contents, interception) + let interceptEnabled = false + try { + await send(contents, 'Input.setInterceptDrags', { enabled: true }) + interceptEnabled = true + } catch { + // Chromium without drag interception: the pointer-only path still works + // for pointer-sensor drags; native HTML5 sources will report no effect. + } + await moveMouse(contents, from.x, from.y) + let pressed = false + let dragEnterSent = false + const dragMove = async (x: number, y: number) => { + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: dragEnterSent ? 'dragOver' : 'dragEnter', + x, + y, + data: interception.data, + }) + dragEnterSent = true + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', + x, + y, + button: 'left', + buttons: 1, + }) + } + } + try { + pressed = true + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mousePressed', + x: from.x, + y: from.y, + button: 'left', + buttons: 1, + clickCount: 1, + }) + // Small first nudge so libraries with a start threshold (commonly 3-8px) + // register the drag before the pointer sweeps across the page. + await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2) + await sleep(stepDelayMs) + const stepCount = Math.max(2, steps) + for (let step = 1; step <= stepCount; step++) { + const progress = step / stepCount + await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress) + await sleep(stepDelayMs) + } + // Hold over the target so drop zones running enter/over animations settle + // before the release lands. + await sleep(120) + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: 'drop', + x: to.x, + y: to.y, + data: interception.data, + }) + // Blink ended the intercepted drag session itself; a trailing + // mouseReleased would be a stray click on the drop target. + pressed = false + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: to.x, + y: to.y, + button: 'left', + buttons: 0, + clickCount: 1, + }) + pressed = false + } + return { nativeDragIntercepted: interception.intercepted } + } finally { + if (pressed && !contents.isDestroyed()) { + if (interception.intercepted && interception.data) { + await sendInput(contents, 'Input.dispatchDragEvent', { + type: 'dragCancel', + x: to.x, + y: to.y, + data: interception.data, + }).catch(() => {}) + } else { + await sendInput(contents, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: to.x, + y: to.y, + button: 'left', + buttons: 0, + clickCount: 1, + }).catch(() => {}) + } + } + dragInterceptionsByContents.delete(contents) + if (interceptEnabled && !contents.isDestroyed()) { + await send(contents, 'Input.setInterceptDrags', { enabled: false }).catch(() => {}) + } + } +} + /** * Inserts text at the focused element's selection (replacing it) through the * native IME path — works in plain fields and code editors alike. diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 5970afec615..38eea541b68 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1874,4 +1874,213 @@ describe('credential protection', () => { expect(result).toMatchObject({ ok: true, result: { dispatched: true } }) expect(clickReads).toBeGreaterThan(1) }) + + it('clicks a coordinate point with native input and reports the target', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { + found: true, + element: 'button "Send"', + editable: false, + secret: false, + fileInput: false, + cursor: 'pointer', + }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 120, y: 240 }) + + expect(result).toMatchObject({ + ok: true, + result: { + dispatched: true, + trusted: true, + clickedAt: { x: 120, y: 240 }, + target: 'button "Send"', + }, + }) + const presses = cdpCalls(contents, 'Input.dispatchMouseEvent').filter( + ([, event]) => (event as { type?: string }).type === 'mousePressed' + ) + expect(presses).toHaveLength(1) + }) + + it('double-clicks a coordinate point as a rising clickCount sequence', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'canvas', editable: false }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { + x: 10, + y: 20, + clickCount: 2, + }) + + expect(result).toMatchObject({ ok: true, result: { clickCount: 2 } }) + const counts = cdpCalls(contents, 'Input.dispatchMouseEvent') + .filter(([, event]) => (event as { type?: string }).type === 'mousePressed') + .map(([, event]) => (event as { clickCount?: number }).clickCount) + expect(counts).toEqual([1, 2]) + }) + + it('refuses a coordinate click on a file input', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'input', fileInput: true }, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 5, y: 5 }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/file input/) + expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0) + }) + + it('rejects a coordinate click outside the viewport with mapping guidance', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { error: 'outside-viewport' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 9999, y: 5 }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/divide image pixels by its scale/) + }) + + it('inserts text into the focused editable at the caret', async () => { + const contents = await openPage() + respondWith(contents, { + activeElementSecrecy: 'safe', + describeFocusedEditable: { editable: true, kind: 'contenteditable' }, + readActiveElementState: { activeElement: 'div', valueLength: 12 }, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { + text: 'hello world', + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, trusted: true, kind: 'contenteditable', insertedChars: 11 }, + }) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1) + }) + + it('refuses insertion when nothing editable holds focus', async () => { + const contents = await openPage() + respondWith(contents, { + activeElementSecrecy: 'safe', + describeFocusedEditable: { editable: false, reason: 'none' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/No element is focused/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('refuses insertion while a password field holds focus', async () => { + const contents = await openPage() + respondWith(contents, { activeElementSecrecy: 'secret' }) + + const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/Refusing to act on a password field/) + expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0) + }) + + it('drags between coordinate points through the trusted pointer pipeline', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'div "Card"' }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromX: 40, + fromY: 50, + toX: 200, + toY: 260, + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, trusted: true, from: { x: 40, y: 50 }, to: { x: 200, y: 260 } }, + }) + const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map( + ([, event]) => (event as { type?: string }).type + ) + expect(events[0]).toBe('mouseMoved') + expect(events).toContain('mousePressed') + expect(events[events.length - 1]).toBe('mouseReleased') + expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0) + }) + + it('drags from a snapshot element to a coordinate target', async () => { + const contents = await openPage() + respondWith(contents, { + clickElement: { dispatched: false, x: 24, y: 48, element: 'Card "Ship it"' }, + describePointTarget: { found: true, element: 'section "Done"' }, + readActiveElementState: {}, + readPageActionState: {}, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromElementId: 0, + toX: 300, + toY: 60, + }) + + expect(result).toMatchObject({ + ok: true, + result: { dispatched: true, from: { x: 24, y: 48, element: 'Card "Ship it"' } }, + }) + }) + + it('rejects a drag whose endpoints are the same point', async () => { + const contents = await openPage() + respondWith(contents, { + describePointTarget: { found: true, element: 'div' }, + }) + + const result = await driver.executeTool('chat-test', 'browser_drag', { + fromX: 10, + fromY: 10, + toX: 10, + toY: 10, + }) + + expect(result.ok).toBe(false) + expect(result.error).toMatch(/same point/) + }) + + it('returns the screenshot scale for coordinate mapping', async () => { + const contents = await openPage() + vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { + if (method === 'Page.getLayoutMetrics') { + return Promise.resolve({ + cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 }, + }) + } + if (method === 'Page.captureScreenshot') { + return Promise.resolve({ data: 'c2lt' }) + } + return Promise.resolve(undefined) + }) + respondWith(contents, { getViewportInfo: { width: 2048, height: 1024 } }) + + const result = await driver.executeTool('chat-test', 'browser_screenshot', {}) + + expect(result).toMatchObject({ ok: true, result: { scale: 0.5 } }) + }) }) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index f6c43f21c60..1c1003f36ed 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -46,6 +46,8 @@ import { activeElementSecrecy, clickElement, collectSnapshot, + describeFocusedEditable, + describePointTarget, focusElementForTyping, getViewportInfo, hoverElement, @@ -892,6 +894,11 @@ function unwrapPageResult(result: unknown): unknown { if (code === 'not-editable') { throw new ToolError('That element is not a text input — pick an editable element.') } + if (code === 'outside-viewport') { + throw new ToolError( + 'That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale, and scroll the target into view first.' + ) + } if (code === 'ambiguous-editable') { throw new ToolError( 'That composite control contains multiple editable fields. Take a fresh browser_snapshot and target the exact field.' @@ -2002,19 +2009,21 @@ async function executeToolInner( case 'browser_screenshot': { const contents = session.requireAutomationTab().view.webContents - const dataUrl = await cdp.captureScreenshot(contents).catch(() => null) - if (dataUrl === null) { + const shot = await cdp.captureScreenshot(contents).catch(() => null) + if (shot === null) { throw new ToolError( 'Could not capture the page. Use browser_snapshot or browser_read_text instead.' ) } - if (dataUrl.length > 8_000_000) { + if (shot.dataUrl.length > 8_000_000) { throw new ToolError( 'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.' ) } const viewport = await execInPage(contents, getViewportInfo, []).catch(() => null) - return { dataUrl, viewport } + // scale maps image pixels back to CSS viewport pixels for the + // coordinate tools: cssX = imageX / scale. + return { dataUrl: shot.dataUrl, viewport, scale: shot.scale } } case 'browser_extract': { @@ -3106,6 +3115,287 @@ async function executeToolInner( } } + case 'browser_click_at': { + const clickedTab = session.requireAutomationTab() + const contents = clickedTab.view.webContents + const x = requireNum(params, 'x') + const y = requireNum(params, 'y') + const clickCount = num(params, 'clickCount') ?? 1 + if (![1, 2, 3].includes(clickCount)) { + throw new ToolError('clickCount must be 1 (click), 2 (double-click), or 3 (triple-click).') + } + const clickNavigationEpoch = navigationEpoch(contents) + assertCurrentExecution() + assertActiveContents(contents) + const pointTarget = unwrapPageResult( + await execInPage(contents, describePointTarget, [x, y], false, executionDeadline) + ) + if (!isRecordLike(pointTarget) || pointTarget.found !== true) { + throw new ToolError( + 'Nothing is rendered at that point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.' + ) + } + if (pointTarget.fileInput === true) { + throw new ToolError( + 'Refusing to click a file input because it opens a native chooser the browser agent cannot inspect or complete. Ask the user to upload the file themselves.' + ) + } + const beforePage = await pageActionState(contents, true) + const beforeElement = await activeElementState(contents) + assertCurrentExecution() + assertActiveContents(contents, clickNavigationEpoch) + try { + await cdp.clickAt(contents, x, y, true, clickCount) + } catch (error) { + throw new ToolError( + `Native click dispatch failed (${getErrorMessage(error)}). The action was not retried because a partial pointer press may already have reached the page. Take a fresh snapshot before continuing.` + ) + } + await sleep(150) + const afterElement = await activeElementState(contents) + const afterPage = await pageActionState(contents) + const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement) + const activeTab = session.automationTab() + const tabChanged = activeTab?.id !== clickedTab.id + const effectObserved = + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.popupChanged || + observation.effect.targetChanged || + (pointTarget.editable === true && observation.effect.focusChanged) || + tabChanged + const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : [] + const notes: string[] = [] + if (pointTarget.secret === true) { + notes.push( + 'The point resolves to a password field. Focusing it is fine, but typing there is refused — call browser_request_takeover for credentials.' + ) + } + if (pointTarget.crossOriginFrame === true) { + notes.push( + 'The point lands inside an embedded frame that could not be inspected; the click was dispatched but its target is unverified.' + ) + } + if (!effectObserved) { + notes.push( + observation.possibleEffectObserved || tabChanged + ? 'Only background DOM/title churn followed the click; inspect the page before treating it as successful.' + : 'No strong observable page change followed the click; inspect the page before treating it as successful.' + ) + } + return { + dispatched: true, + trusted: true, + activation: 'native-pointer', + clickedAt: { x, y }, + clickCount, + target: pointTarget.element, + targetCursor: pointTarget.cursor, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved || tabChanged, + effect: { ...observation.effect, tabChanged }, + dialogs, + ...(tabChanged && activeTab + ? { activeTab: { tabId: activeTab.id, url: activeTab.view.webContents.getURL() } } + : {}), + ...(notes.length > 0 ? { note: notes.join(' ') } : {}), + } + } + + case 'browser_insert_text': { + const text = requireStr(params, 'text') + const submit = params.submit === true + const contents = session.requireAutomationTab().view.webContents + const insertNavigationEpoch = navigationEpoch(contents) + const target: PageExecutionTarget = focusedPageTarget(contents) + const secrecy = await execInPage(target, activeElementSecrecy, []).catch(() => 'opaque') + if (secrecy === 'secret') throw new ToolError(PASSWORD_REFUSAL) + if (secrecy === 'opaque') { + throw new ToolError( + 'Focus is inside a cross-origin frame whose contents cannot be inspected, so this ' + + 'insertion could reach a password field. Call browser_request_takeover if the user needs to type here.' + ) + } + const focusState = unwrapPageResult( + await execInPage(target, describeFocusedEditable, [], false, executionDeadline) + ) + if (!isRecordLike(focusState) || focusState.editable !== true) { + const reason = isRecordLike(focusState) ? String(focusState.reason || '') : '' + throw new ToolError( + reason === 'none' + ? 'No element is focused. Click the field first (browser_click or browser_click_at), then insert text.' + : `The focused element does not accept text${reason ? ` (${reason})` : ''}. Focus an editable field first.` + ) + } + const beforePage = await pageActionState(target, true) + const beforeElement = await activeElementState(target) + assertCurrentExecution() + assertActiveContents(contents, insertNavigationEpoch) + assertFocusedTargetUnchanged(contents, target) + try { + await cdp.insertText(contents, text) + } catch (error) { + throw new ToolError( + `Native text insertion failed (${getErrorMessage(error)}). Take a fresh snapshot before retrying.` + ) + } + let submitDispatched = false + if (submit) { + await sleep(25) + assertCurrentExecution() + assertActiveContents(contents, insertNavigationEpoch) + try { + await dispatchKeyCombo(contents, parseKeyCombo('Enter')) + submitDispatched = true + } catch { + // Reported below through submitDispatched: false. + } + } + await sleep(150) + const state = await activeElementState(target) + const afterPage = await pageActionState(target) + const observation = pageEffect(beforePage, afterPage, beforeElement, state) + const effectObserved = + observation.effect.fieldChanged || + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.targetChanged + return { + dispatched: true, + trusted: true, + kind: focusState.kind, + insertedChars: text.length, + ...state, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved, + effect: observation.effect, + submitRequested: submit, + submitDispatched, + ...(focusState.kind === 'canvas' || focusState.kind === 'textbox-role' + ? { + note: 'The focused editor is canvas/model-backed, so field readback cannot confirm the text — verify visually with browser_screenshot.', + } + : !effectObserved + ? { + note: 'Insertion produced no observable field or page change; inspect the page before continuing.', + } + : {}), + } + } + + case 'browser_drag': { + const draggedTab = session.requireAutomationTab() + const contents = draggedTab.view.webContents + const dragNavigationEpoch = navigationEpoch(contents) + + const resolveEndpoint = async ( + which: 'from' | 'to' + ): Promise<{ x: number; y: number; element?: string }> => { + const elementId = num(params, `${which}ElementId`) + if (elementId !== undefined) { + const target = pageTargetForElement(contents, elementId) + if (frameExecutionTarget(target, contents)) { + throw new ToolError( + `Dragging elements inside embedded frames is not supported. Use ${which}X/${which}Y viewport coordinates instead.` + ) + } + const prepared = unwrapPageResult( + await execInPageWithSettleGrace( + target, + clickElement, + [elementId, false, false], + executionDeadline + ) + ) + if ( + !isRecordLike(prepared) || + typeof prepared.x !== 'number' || + typeof prepared.y !== 'number' + ) { + throw new ToolError(`Could not resolve the ${which} element for the drag.`) + } + return { + x: prepared.x, + y: prepared.y, + ...(typeof prepared.element === 'string' ? { element: prepared.element } : {}), + } + } + const pointX = num(params, `${which}X`) + const pointY = num(params, `${which}Y`) + if (pointX === undefined || pointY === undefined) { + throw new ToolError( + `Provide either ${which}ElementId or both ${which}X and ${which}Y for the drag ${which === 'from' ? 'source' : 'target'}.` + ) + } + const probe = unwrapPageResult( + await execInPage( + contents, + describePointTarget, + [pointX, pointY], + false, + executionDeadline + ) + ) + if (!isRecordLike(probe) || probe.found !== true) { + throw new ToolError( + `Nothing is rendered at the ${which} point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.` + ) + } + return { + x: pointX, + y: pointY, + ...(typeof probe.element === 'string' ? { element: probe.element } : {}), + } + } + + assertCurrentExecution() + assertActiveContents(contents) + const from = await resolveEndpoint('from') + const to = await resolveEndpoint('to') + if (Math.abs(from.x - to.x) < 1 && Math.abs(from.y - to.y) < 1) { + throw new ToolError('The drag source and target are the same point; nothing to drag.') + } + const beforePage = await pageActionState(contents, true) + const beforeElement = await activeElementState(contents) + assertCurrentExecution() + assertActiveContents(contents, dragNavigationEpoch) + let interception: { nativeDragIntercepted: boolean } + try { + interception = await cdp.dragPointer(contents, from, to) + } catch (error) { + throw new ToolError( + `Native drag dispatch failed (${getErrorMessage(error)}). The pointer may have been mid-drag; take a fresh snapshot to see the page's current state before retrying.` + ) + } + await sleep(200) + const afterElement = await activeElementState(contents) + const afterPage = await pageActionState(contents) + const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement) + const effectObserved = + observation.effect.domChanged || + observation.effect.urlChanged || + observation.effect.dialogChanged || + observation.effect.targetChanged || + observation.effect.scrollChanged + const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : [] + return { + dispatched: true, + trusted: true, + nativeHtml5Drag: interception.nativeDragIntercepted, + from, + to, + effectObserved, + possibleEffectObserved: observation.possibleEffectObserved, + effect: observation.effect, + dialogs, + ...(!effectObserved + ? { + note: 'No observable page change followed the drag. Verify with browser_snapshot or browser_screenshot; some drop targets only commit on their own animation frame.', + } + : {}), + } + } + case 'browser_request_takeover': { // The reason renders in the chat's tool row, not here — but require it // so the model always tells the user why control was handed over. diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index a74f95bf9c8..a8f6b8816e0 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -5,6 +5,8 @@ import { activeElementSecrecy, clickElement, collectSnapshot, + describeFocusedEditable, + describePointTarget, focusElementForTyping, getViewportInfo, hoverElement, @@ -155,6 +157,8 @@ describe('serialization contract', () => { ['readPageText', readPageText, []], ['pageContainsText', pageContainsText, ['needle']], ['getViewportInfo', getViewportInfo, []], + ['describePointTarget', describePointTarget, [10, 10]], + ['describeFocusedEditable', describeFocusedEditable, []], ] it.each(cases)('%s is self-contained', (_name, fn, args) => { @@ -1373,3 +1377,80 @@ describe('pressKeyOnPage', () => { expect(seen).toEqual(['a']) }) }) + +describe('describePointTarget', () => { + function pointAt(el: Element | null): void { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: () => el, + }) + } + + it('describes the element at a viewport point', () => { + document.body.innerHTML = '' + pointAt(document.querySelector('button')) + + expect(describePointTarget(10, 10)).toMatchObject({ + found: true, + tag: 'button', + element: 'button "Send message"', + editable: false, + fileInput: false, + secret: false, + }) + }) + + it('flags file inputs and password fields at the point', () => { + document.body.innerHTML = '' + pointAt(document.querySelector('input')) + expect(describePointTarget(10, 10)).toMatchObject({ found: true, fileInput: true }) + + document.body.innerHTML = '' + pointAt(document.querySelector('input')) + expect(describePointTarget(10, 10)).toMatchObject({ + found: true, + secret: true, + editable: true, + }) + }) + + it('rejects points outside the viewport', () => { + expect(describePointTarget(-5, 10)).toEqual({ error: 'outside-viewport' }) + expect(describePointTarget(10, window.innerHeight + 5)).toEqual({ + error: 'outside-viewport', + }) + }) +}) + +describe('describeFocusedEditable', () => { + it('reports no focus when the body holds focus', () => { + setActiveElement(document, document.body) + expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' }) + }) + + it('reports a writable input as insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'input:text' }) + }) + + it('reports a read-only input as not insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('input')) + expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'readonly' }) + }) + + it('reports a focused contenteditable editor as insertable', () => { + document.body.innerHTML = '
' + const editor = document.querySelector('div') as HTMLElement + Object.defineProperty(editor, 'isContentEditable', { get: () => true }) + setActiveElement(document, editor) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) + }) + + it('treats a focused canvas editor surface as insertable', () => { + document.body.innerHTML = '' + setActiveElement(document, document.querySelector('canvas')) + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' }) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 3bdb88a64c0..33af0d6f964 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -2676,3 +2676,155 @@ export function getViewportInfo(): unknown { height: window.innerHeight, } } + +/** + * Describes whatever sits at a viewport point, descending through open shadow + * roots and same-origin iframes. Coordinate-addressed actions have no + * snapshot ref to revalidate, so this probe is their safety check: the driver + * refuses file inputs outright and reports what the point resolves to so the + * model can confirm it hit what the screenshot showed. + */ +export function describePointTarget(x: number, y: number): unknown { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + x < 0 || + y < 0 || + x >= window.innerWidth || + y >= window.innerHeight + ) { + return { error: 'outside-viewport' } + } + + let doc: Document = document + let localX = x + let localY = y + let element: Element | null = null + for (let depth = 0; depth < 10; depth++) { + if (typeof doc.elementFromPoint !== 'function') break + let found: Element | null = doc.elementFromPoint(localX, localY) + // Open shadow roots re-hit-test at the same point until a leaf host. + for (let shadowDepth = 0; shadowDepth < 10; shadowDepth++) { + const shadow = (found as HTMLElement | null)?.shadowRoot + const inner = shadow?.elementFromPoint(localX, localY) + if (!inner || inner === found) break + found = inner + } + element = found + const tag = String(found?.tagName || '').toUpperCase() + if ((tag !== 'IFRAME' && tag !== 'FRAME') || !found) break + try { + const innerDoc = (found as HTMLIFrameElement).contentDocument + if (!innerDoc) break + const rect = found.getBoundingClientRect() + localX -= rect.left + (found as HTMLIFrameElement).clientLeft + localY -= rect.top + (found as HTMLIFrameElement).clientTop + doc = innerDoc + } catch { + // Cross-origin frame — cannot inspect further; report the frame itself. + break + } + } + if (!element) return { found: false } + + const tag = String(element.tagName || '').toUpperCase() + const inputType = + tag === 'INPUT' ? String((element as HTMLInputElement).type || 'text').toLowerCase() : '' + const secret = + tag === 'INPUT' && + (inputType === 'password' || + String(element.getAttribute('autocomplete') || '') + .toLowerCase() + .split(/\s+/) + .some((token) => token === 'current-password' || token === 'new-password')) + const editable = Boolean( + tag === 'TEXTAREA' || + (tag === 'INPUT' && + ['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) || + (element as HTMLElement).isContentEditable + ) + + const name = ( + element.getAttribute('aria-label') || + element.getAttribute('title') || + element.getAttribute('alt') || + ((element as HTMLElement).innerText ?? element.textContent ?? '') + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80) + const role = element.getAttribute('role') || '' + const style = element.ownerDocument.defaultView?.getComputedStyle(element) + + return { + found: true, + element: name + ? `${tag.toLowerCase()}${role ? `[${role}]` : ''} "${name}"` + : `${tag.toLowerCase()}${role ? `[${role}]` : ''}`, + tag: tag.toLowerCase(), + role, + editable, + secret, + fileInput: tag === 'INPUT' && inputType === 'file', + disabled: + (element as HTMLInputElement).disabled === true || + element.getAttribute('aria-disabled') === 'true', + canvas: tag === 'CANVAS', + crossOriginFrame: tag === 'IFRAME' || tag === 'FRAME', + cursor: style?.cursor || '', + } +} + +/** + * Reports whether the currently-focused element accepts text insertion, for + * browser_insert_text — which types at the caret instead of addressing a + * snapshot ref. Secrecy is separately (and authoritatively) probed by + * activeElementSecrecy before any insertion. + */ +export function describeFocusedEditable(): unknown { + let active = document.activeElement as HTMLElement | null + for (let depth = 0; active && depth < 10; depth++) { + const shadow = active.shadowRoot + if (shadow?.activeElement) { + active = shadow.activeElement as HTMLElement + continue + } + break + } + if (!active || active === document.body) return { editable: false, reason: 'none' } + const tag = String(active.tagName || '').toUpperCase() + const inputType = + tag === 'INPUT' ? String((active as HTMLInputElement).type || 'text').toLowerCase() : '' + if (tag === 'INPUT' || tag === 'TEXTAREA') { + const field = active as HTMLInputElement | HTMLTextAreaElement + if (field.disabled || active.getAttribute('aria-disabled') === 'true') { + return { editable: false, reason: 'disabled' } + } + if (field.readOnly || active.getAttribute('aria-readonly') === 'true') { + return { editable: false, reason: 'readonly' } + } + if ( + tag === 'INPUT' && + !['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType) + ) { + return { editable: false, reason: 'not-text' } + } + return { editable: true, kind: tag === 'TEXTAREA' ? 'textarea' : `input:${inputType}` } + } + if (active.isContentEditable) { + if (active.getAttribute('aria-disabled') === 'true') { + return { editable: false, reason: 'disabled' } + } + if (active.getAttribute('aria-readonly') === 'true') { + return { editable: false, reason: 'readonly' } + } + return { editable: true, kind: 'contenteditable' } + } + // A canvas-rendered editor (Google Docs) focuses a hidden proxy or the + // canvas region itself; trusted IME insertion still reaches it, so report + // it as insertable rather than refusing. + if (tag === 'CANVAS' || active.getAttribute('role') === 'textbox') { + return { editable: true, kind: tag === 'CANVAS' ? 'canvas' : 'textbox-role' } + } + return { editable: false, reason: 'not-editable' } +} diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9ba13a5aab6..7648644e3f1 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -11,11 +11,14 @@ export interface ToolCatalogEntry { | 'auth' | 'browser' | 'browser_click' + | 'browser_click_at' | 'browser_close_tab' + | 'browser_drag' | 'browser_extract' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' + | 'browser_insert_text' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -132,11 +135,14 @@ export interface ToolCatalogEntry { | 'auth' | 'browser' | 'browser_click' + | 'browser_click_at' | 'browser_close_tab' + | 'browser_drag' | 'browser_extract' | 'browser_go_back' | 'browser_go_forward' | 'browser_hover' + | 'browser_insert_text' | 'browser_list_sessions' | 'browser_list_tabs' | 'browser_navigate' @@ -450,6 +456,135 @@ export const BrowserClick: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserClickAt: ToolCatalogEntry = { + id: 'browser_click_at', + name: 'browser_click_at', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + clickCount: { + type: 'number', + description: '1 = single click (default), 2 = double-click, 3 = triple-click.', + }, + x: { + type: 'number', + description: + "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.", + }, + y: { + type: 'number', + description: + 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + }, + }, + required: ['x', 'y'], + }, + resultSchema: { + type: 'object', + properties: { + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { type: 'string', description: 'Stable browser tab id.' }, + url: { type: 'string', description: 'New active tab URL.' }, + }, + }, + clickCount: { + type: 'number', + description: 'The click count that was dispatched (1, 2, or 3).', + }, + clickedAt: { + type: 'object', + description: 'The CSS-pixel viewport point that was clicked.', + properties: { + x: { type: 'number', description: 'Clicked X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Clicked Y in CSS viewport pixels.' }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { type: 'string' }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the native pointer click was dispatched at the point.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.', + }, + note: { + type: 'string', + description: 'Caution or follow-up guidance, present when the click needs verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + target: { + type: 'string', + description: + 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.', + }, + targetCursor: { + type: 'string', + description: + "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.", + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserCloseTab: ToolCatalogEntry = { id: 'browser_close_tab', name: 'browser_close_tab', @@ -468,6 +603,130 @@ export const BrowserCloseTab: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserDrag: ToolCatalogEntry = { + id: 'browser_drag', + name: 'browser_drag', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + fromElementId: { + type: 'number', + description: 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.', + }, + fromX: { + type: 'number', + description: + 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.', + }, + fromY: { type: 'number', description: 'Drag source Y in CSS viewport pixels.' }, + toElementId: { + type: 'number', + description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.', + }, + toX: { + type: 'number', + description: 'Drop target X in CSS viewport pixels (paired with toY).', + }, + toY: { type: 'number', description: 'Drop target Y in CSS viewport pixels.' }, + }, + }, + resultSchema: { + type: 'object', + properties: { + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { type: 'string' }, + }, + dispatched: { type: 'boolean', description: 'Whether the full drag gesture was dispatched.' }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.', + }, + from: { + type: 'object', + description: 'The resolved drag source.', + properties: { + element: { type: 'string', description: 'What that endpoint resolved to, when known.' }, + x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' }, + }, + }, + nativeHtml5Drag: { + type: 'boolean', + description: + 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + to: { + type: 'object', + description: 'The resolved drop target.', + properties: { + element: { type: 'string', description: 'What that endpoint resolved to, when known.' }, + x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' }, + y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' }, + }, + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserExtract: ToolCatalogEntry = { id: 'browser_extract', name: 'browser_extract', @@ -630,6 +889,116 @@ export const BrowserHover: ToolCatalogEntry = { clientExecutable: true, } +export const BrowserInsertText: ToolCatalogEntry = { + id: 'browser_insert_text', + name: 'browser_insert_text', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + submit: { type: 'boolean', description: 'Press Enter after inserting. Default false.' }, + text: { type: 'string', description: 'The text to insert at the caret. Must be non-empty.' }, + }, + required: ['text'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { type: 'string', description: 'Focused element kind after the action.' }, + dispatched: { + type: 'boolean', + description: 'Whether the text was inserted through the native IME pipeline.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.', + }, + insertedChars: { type: 'number', description: 'How many characters were inserted.' }, + kind: { + type: 'string', + description: + 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn was observed.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submitDispatched: { type: 'boolean', description: 'Whether Enter was actually dispatched.' }, + submitRequested: { + type: 'boolean', + description: 'Whether Enter was requested after insertion.', + }, + trusted: { + type: 'boolean', + description: 'True — insertion uses the trusted input pipeline.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + export const BrowserListSessions: ToolCatalogEntry = { id: 'browser_list_sessions', name: 'browser_list_sessions', @@ -6617,11 +6986,14 @@ export const TOOL_CATALOG: Record = { [Auth.id]: Auth, [Browser.id]: Browser, [BrowserClick.id]: BrowserClick, + [BrowserClickAt.id]: BrowserClickAt, [BrowserCloseTab.id]: BrowserCloseTab, + [BrowserDrag.id]: BrowserDrag, [BrowserExtract.id]: BrowserExtract, [BrowserGoBack.id]: BrowserGoBack, [BrowserGoForward.id]: BrowserGoForward, [BrowserHover.id]: BrowserHover, + [BrowserInsertText.id]: BrowserInsertText, [BrowserListSessions.id]: BrowserListSessions, [BrowserListTabs.id]: BrowserListTabs, [BrowserNavigate.id]: BrowserNavigate, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3bebb7cd523..a75d3ddd632 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -214,6 +214,160 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['dispatched'], }, }, + browser_click_at: { + parameters: { + type: 'object', + properties: { + clickCount: { + type: 'number', + description: '1 = single click (default), 2 = double-click, 3 = triple-click.', + }, + x: { + type: 'number', + description: + "X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.", + }, + y: { + type: 'number', + description: + 'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.', + }, + }, + required: ['x', 'y'], + }, + resultSchema: { + type: 'object', + properties: { + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { + type: 'string', + description: 'Stable browser tab id.', + }, + url: { + type: 'string', + description: 'New active tab URL.', + }, + }, + }, + clickCount: { + type: 'number', + description: 'The click count that was dispatched (1, 2, or 3).', + }, + clickedAt: { + type: 'object', + description: 'The CSS-pixel viewport point that was clicked.', + properties: { + x: { + type: 'number', + description: 'Clicked X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Clicked Y in CSS viewport pixels.', + }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { + type: 'string', + }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the native pointer click was dispatched at the point.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.', + }, + note: { + type: 'string', + description: 'Caution or follow-up guidance, present when the click needs verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + target: { + type: 'string', + description: + 'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.', + }, + targetCursor: { + type: 'string', + description: + "The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.", + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + }, browser_close_tab: { parameters: { type: 'object', @@ -227,6 +381,171 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + browser_drag: { + parameters: { + type: 'object', + properties: { + fromElementId: { + type: 'number', + description: + 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.', + }, + fromX: { + type: 'number', + description: + 'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.', + }, + fromY: { + type: 'number', + description: 'Drag source Y in CSS viewport pixels.', + }, + toElementId: { + type: 'number', + description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.', + }, + toX: { + type: 'number', + description: 'Drop target X in CSS viewport pixels (paired with toY).', + }, + toY: { + type: 'number', + description: 'Drop target Y in CSS viewport pixels.', + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { + type: 'string', + }, + }, + dispatched: { + type: 'boolean', + description: 'Whether the full drag gesture was dispatched.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.', + }, + from: { + type: 'object', + description: 'The resolved drag source.', + properties: { + element: { + type: 'string', + description: 'What that endpoint resolved to, when known.', + }, + x: { + type: 'number', + description: 'Endpoint X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Endpoint Y in CSS viewport pixels.', + }, + }, + }, + nativeHtml5Drag: { + type: 'boolean', + description: + 'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn (DOM/title) was observed.', + }, + to: { + type: 'object', + description: 'The resolved drop target.', + properties: { + element: { + type: 'string', + description: 'What that endpoint resolved to, when known.', + }, + x: { + type: 'number', + description: 'Endpoint X in CSS viewport pixels.', + }, + y: { + type: 'number', + description: 'Endpoint Y in CSS viewport pixels.', + }, + }, + }, + trusted: { + type: 'boolean', + description: + 'True — coordinate and drag input always use the trusted Chromium pointer pipeline.', + }, + }, + required: ['dispatched'], + }, + }, browser_extract: { parameters: { type: 'object', @@ -410,6 +729,142 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['hovered'], }, }, + browser_insert_text: { + parameters: { + type: 'object', + properties: { + submit: { + type: 'boolean', + description: 'Press Enter after inserting. Default false.', + }, + text: { + type: 'string', + description: 'The text to insert at the caret. Must be non-empty.', + }, + }, + required: ['text'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { + type: 'string', + description: 'Focused element kind after the action.', + }, + dispatched: { + type: 'boolean', + description: 'Whether the text was inserted through the native IME pipeline.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.', + }, + insertedChars: { + type: 'number', + description: 'How many characters were inserted.', + }, + kind: { + type: 'string', + description: + 'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).', + }, + note: { + type: 'string', + description: + 'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Whether only weaker background churn was observed.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submitDispatched: { + type: 'boolean', + description: 'Whether Enter was actually dispatched.', + }, + submitRequested: { + type: 'boolean', + description: 'Whether Enter was requested after insertion.', + }, + trusted: { + type: 'boolean', + description: 'True — insertion uses the trusted input pipeline.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, + }, browser_list_sessions: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index b274fdd2bdf..5981910cd0a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -525,7 +525,10 @@ const TOOL_TITLES: Record = { browser_read_text: 'Reading page', browser_screenshot: 'Taking screenshot', browser_click: 'Clicking element', + browser_click_at: 'Clicking point', browser_type: 'Typing text', + browser_insert_text: 'Inserting text', + browser_drag: 'Dragging element', browser_select_option: 'Selecting option', browser_hover: 'Hovering element', // Subagent trigger tools, when surfaced as a tool call. @@ -1015,6 +1018,8 @@ const COMPLETED_VERB_REWRITES: Record = { Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', + Dragging: 'Dragged', + Inserting: 'Inserted', Publishing: 'Published', Unpublishing: 'Unpublished', Analyzing: 'Analyzed', diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts index 5158764dd0b..ca7dd65cbd1 100644 --- a/packages/browser-protocol/src/index.ts +++ b/packages/browser-protocol/src/index.ts @@ -33,11 +33,14 @@ export const BROWSER_TOOL_NAMES = [ 'browser_screenshot', 'browser_extract', 'browser_click', + 'browser_click_at', 'browser_type', + 'browser_insert_text', 'browser_press_key', 'browser_scroll', 'browser_select_option', 'browser_hover', + 'browser_drag', 'browser_request_takeover', ] as const From 0cab4d058987631daa2323f576de7f52cb48c58e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 15:28:48 -0700 Subject: [PATCH 015/135] Revert subagent group eager auto-collapse --- .../agent-group/agent-group.test.ts | 3 +++ .../components/agent-group/agent-group.tsx | 27 ++++++++++--------- .../message-content/message-content.tsx | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 9f83098c06a..4808893b4eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -110,6 +110,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [tool('success'), browserTakeover(reason)], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) @@ -183,6 +184,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [takeover], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) @@ -204,6 +206,7 @@ describe('AgentGroup browser takeover', () => { agentLabel: 'Browser Agent', items: [completedTakeover], isStreaming: true, + isCurrentSection: true, isLaneOpen: true, }) ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 070734ddf74..103e6ba6e4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -37,6 +37,8 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean + /** This group is the latest section in its parent sequence (drives collapse). */ + isCurrentSection?: boolean /** The subagent lane is still open (no subagent_end yet) — i.e. actively running. */ isLaneOpen?: boolean } @@ -108,6 +110,7 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, + isCurrentSection = false, isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -120,18 +123,17 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and the subagent is still working: the lane - // is open, or there is unresolved work. When the lane closes and the work - // resolves the group collapses — with parallel subagents, finished siblings - // fold away while the still-running ones stay open, instead of every group - // lingering expanded until the next section starts. Keying "still running" - // off the lane-open signal (not `resolved` alone) avoids a collapse/reopen - // flicker mid-run: a subagent's tools all momentarily read "done" in the gap - // between its last search and its `respond` ("Gathering thoughts") tool, - // transiently flipping `resolved` true; the open lane bridges that gap. The - // turn ending (isStreaming false) collapses everything; a manual toggle pins - // the choice. - const autoExpanded = isStreaming && (isLaneOpen || !resolved) + // Expand while the turn is live and any of: the lane is open (the subagent is + // actively running), this is the current/latest section, or there is unresolved + // work. A finished group stays open until the NEXT section starts (it is no + // longer the latest), instead of collapsing the instant its own work resolves. + // Keying "still running" off the lane-open signal (not `resolved` alone) avoids + // a collapse/reopen flicker on parallel siblings: a subagent's tools all + // momentarily read "done" in the gap between its last search and its `respond` + // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open + // lane bridges that gap so the row never collapses mid-run. The turn ending + // (isStreaming false) collapses everything; a manual toggle pins the choice. + const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -217,6 +219,7 @@ export function AgentGroup({ items={item.group.items} isDelegating={item.group.isDelegating} isStreaming={isStreaming} + isCurrentSection={idx === items.length - 1} isLaneOpen={item.group.isOpen} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 033ecf0cd50..24dfc3fd842 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -957,6 +957,7 @@ function MessageContentInner({ items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} + isCurrentSection={i === segments.length - 1} isLaneOpen={segment.isOpen} /> From 0f3f2faeb3a44c83289b9c3c38bc439875d54e75 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 16:07:07 -0700 Subject: [PATCH 016/135] fix(chat): keep sends FIFO across the streaming-to-idle drain gap --- .../[workspaceId]/home/hooks/use-chat.test.ts | 22 ++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 34 +++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index fa4d09e96b1..ec0fe69942d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -14,6 +14,7 @@ import { reconcileLiveAssistantTurn, selectReconnectReplayState, shouldActivateResourceEvent, + shouldQueueOutgoingMessage, waitForDetachedChatResolution, } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import type { @@ -45,6 +46,27 @@ describe('shouldActivateResourceEvent', () => { }) }) +describe('shouldQueueOutgoingMessage', () => { + it('queues while a send is in flight', () => { + expect(shouldQueueOutgoingMessage(true, false, 0)).toBe(true) + }) + + it('queues while a stop is still settling', () => { + expect(shouldQueueOutgoingMessage(false, true, 0)).toBe(true) + }) + + it('queues behind messages still waiting after the turn ended', () => { + // The regression: a message queued mid-stream must dispatch before one + // typed in the idle gap after the turn stopped — a direct send here would + // jump the queue and swap the user's message order. + expect(shouldQueueOutgoingMessage(false, false, 1)).toBe(true) + }) + + it('sends directly on an idle chat with an empty queue', () => { + expect(shouldQueueOutgoingMessage(false, false, 0)).toBe(false) + }) +}) + function userMessage(id: string): PersistedMessage { return { id, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 926e87362e9..2bef849721d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1202,6 +1202,25 @@ export function shouldActivateResourceEvent( return options?.activate === true || !activeResourceId || activeResourceId === resourceId } +/** + * Whether a fresh outbound message must join the chat's send queue instead of + * dispatching directly. Queueing while a send or stop is in flight is the + * obvious half; the queued-ahead term preserves FIFO across the + * streaming→idle boundary — a message queued while the previous turn streamed + * must reach the model before one typed after that turn ended but before the + * queue drained. Without it the fresh send jumps the queue and both the + * transcript and the model see the user's messages in swapped order. The two + * signals never gap mid-dispatch: a queued message stays in the queue until + * its optimistic send applies, which is after the in-flight flag is set. + */ +export function shouldQueueOutgoingMessage( + sendInFlight: boolean, + stopPending: boolean, + queuedAheadCount: number +): boolean { + return sendInFlight || stopPending || queuedAheadCount > 0 +} + export interface UseChatOptions { onResourceEvent?: ResourceEventHandler apiPath?: string @@ -4188,12 +4207,23 @@ export function useChat( // An in-flight send drains the queue from `finalize`; a pending stop kicks // the dispatcher itself, since nothing else will once the stop settles. - if (sendingRef.current || pendingStopPromiseRef.current) { + // A non-empty queue forces queueing even on an idle chat: messages + // queued while the previous turn streamed must go out first, so a fresh + // send lands behind them instead of jumping the line in the drain gap + // after a turn ends. + const queuedAheadCount = (queueStore.queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE).length + if ( + shouldQueueOutgoingMessage( + Boolean(sendingRef.current), + Boolean(pendingStopPromiseRef.current), + queuedAheadCount + ) + ) { queueStore.enqueue( activeChatKey, createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId) ) - if (pendingStopPromiseRef.current) { + if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) { void enqueueQueueDispatchRef.current({ type: 'send_head' }) } return From 98baa20f351d0f169f30dac0296cb00b4c2cde32 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 16:40:42 -0700 Subject: [PATCH 017/135] Add the steering backend surface for mid-turn sends --- .../app/api/copilot/chat/steer/route.test.ts | 128 ++++++++++++++++++ apps/sim/app/api/copilot/chat/steer/route.ts | 128 ++++++++++++++++++ apps/sim/lib/api/contracts/copilot.ts | 8 ++ .../generated/mothership-stream-v1-schema.ts | 28 +++- .../copilot/generated/mothership-stream-v1.ts | 2 + .../generated/trace-attribute-values-v1.ts | 54 ++++++++ .../copilot/generated/trace-attributes-v1.ts | 76 ++++++++++- .../lib/copilot/generated/trace-spans-v1.ts | 2 + apps/sim/lib/copilot/request/session/steer.ts | 79 +++++++++++ scripts/check-api-validation-contracts.ts | 4 +- 10 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/api/copilot/chat/steer/route.test.ts create mode 100644 apps/sim/app/api/copilot/chat/steer/route.ts create mode 100644 apps/sim/lib/copilot/request/session/steer.ts diff --git a/apps/sim/app/api/copilot/chat/steer/route.test.ts b/apps/sim/app/api/copilot/chat/steer/route.test.ts new file mode 100644 index 00000000000..417d4d96385 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/steer/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthenticate, mockGetLatestRunForStream, mockRequestStreamSteering, mockAppend } = + vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockGetLatestRunForStream: vi.fn(), + mockRequestStreamSteering: vi.fn(), + mockAppend: vi.fn(), + })) + +vi.mock('@/lib/copilot/request/http', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticate, +})) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getLatestRunForStream: mockGetLatestRunForStream, +})) +vi.mock('@/lib/copilot/request/session/steer', () => ({ + requestStreamSteering: mockRequestStreamSteering, +})) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + appendCopilotChatMessages: mockAppend, +})) + +import { POST } from '@/app/api/copilot/chat/steer/route' + +function steerRequest(overrides: Record = {}) { + return createMockRequest('POST', { + streamId: 'stream-1', + chatId: 'chat-1', + steeringId: 'steer-1', + content: 'focus on the tests', + ...overrides, + }) +} + +describe('POST /api/copilot/chat/steer', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) + mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) + mockRequestStreamSteering.mockResolvedValue({ queued: true, status: 200 }) + mockAppend.mockResolvedValue(undefined) + }) + + it('queues steering with Go and persists the user message', async () => { + const response = await POST(steerRequest()) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, queued: true }) + expect(mockRequestStreamSteering).toHaveBeenCalledWith( + expect.objectContaining({ + streamId: 'stream-1', + chatId: 'chat-1', + steeringId: 'steer-1', + content: 'focus on the tests', + userId: 'user-1', + }) + ) + expect(mockAppend).toHaveBeenCalledWith( + 'chat-1', + [ + expect.objectContaining({ + id: 'steer-1', + role: 'user', + content: 'focus on the tests', + }), + ], + { streamId: 'stream-1' } + ) + }) + + it('returns 409 when Go rejects the steer so the client falls back to a normal send', async () => { + mockRequestStreamSteering.mockResolvedValue({ queued: false, status: 429 }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ ok: false, queued: false }) + expect(mockAppend).not.toHaveBeenCalled() + }) + + it('returns 409 when the Go forward throws', async () => { + mockRequestStreamSteering.mockRejectedValue(new Error('network down')) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(409) + expect(mockAppend).not.toHaveBeenCalled() + }) + + it('rejects a chat that does not own the stream', async () => { + mockGetLatestRunForStream.mockResolvedValue({ chatId: 'other-chat' }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(403) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('rejects unauthenticated callers', async () => { + mockAuthenticate.mockResolvedValue({ userId: null, isAuthenticated: false }) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(401) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('rejects an empty content body', async () => { + const response = await POST(steerRequest({ content: '' })) + + expect(response.status).toBe(400) + expect(mockRequestStreamSteering).not.toHaveBeenCalled() + }) + + it('still reports queued when history persistence fails', async () => { + mockAppend.mockRejectedValue(new Error('db down')) + + const response = await POST(steerRequest()) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true, queued: true }) + }) +}) diff --git a/apps/sim/app/api/copilot/chat/steer/route.ts b/apps/sim/app/api/copilot/chat/steer/route.ts new file mode 100644 index 00000000000..7520ac01003 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/steer/route.ts @@ -0,0 +1,128 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { copilotChatSteerBodySchema } from '@/lib/api/contracts/copilot' +import { validationErrorResponse } from '@/lib/api/server' +import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { CopilotSteerOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { requestStreamSteering } from '@/lib/copilot/request/session/steer' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CopilotChatSteerAPI') + +// POST /api/copilot/chat/steer — queues a mid-turn steering message with the +// Go side for a LIVE stream. Acceptance means "queued", not "applied": Go +// acknowledges application with a `run`/`steering_applied` stream event; a +// client that never sees that ack before the stream ends re-sends the content +// as an ordinary message. A 409 here tells the client to take that ordinary +// path immediately. +export const POST = withRouteHandler((request: NextRequest) => + withIncomingGoSpan( + request.headers, + TraceSpan.CopilotChatSteerStream, + undefined, + async (rootSpan) => { + const { userId: authenticatedUserId, isAuthenticated } = + await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !authenticatedUserId) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // boundary-raw-json: tolerant parse; validation happens via the contract schema below + const body = await request.json().catch(() => ({})) + const validation = copilotChatSteerBodySchema.safeParse(body) + if (!validation.success) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return validationErrorResponse(validation.error, 'Invalid request body') + } + const { streamId, chatId, steeringId, content } = validation.data + rootSpan.setAttributes({ + [TraceAttr.StreamId]: streamId, + [TraceAttr.ChatId]: chatId, + [TraceAttr.UserId]: authenticatedUserId, + [TraceAttr.CopilotSteeringContentChars]: content.length, + }) + + // Ownership pre-check on the Sim side (Go re-proves it independently): + // the stream must belong to a run of the authenticated user, and the + // claimed chat must match that run. + const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { + logger.warn('getLatestRunForStream failed while resolving steer context', { + streamId, + error: getErrorMessage(err), + }) + return null + }) + if (run?.chatId && run.chatId !== chatId) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) + return NextResponse.json({ error: 'Stream does not belong to this chat' }, { status: 403 }) + } + + let queued = false + let goStatus = 0 + try { + const result = await requestStreamSteering({ + streamId, + userId: authenticatedUserId, + chatId, + steeringId, + content, + workspaceId: run?.workspaceId ?? undefined, + }) + queued = result.queued + goStatus = result.status + } catch (err) { + logger.warn('Steer forward to Go failed', { + streamId, + chatId, + error: getErrorMessage(err), + }) + } + + if (!queued) { + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.NoActiveTurn) + // 409 = "could not queue; send it as an ordinary message instead". + return NextResponse.json({ ok: false, queued: false, goStatus }, { status: 409 }) + } + + // Persist the steering text as a user message so reloads include it. + // Failure here must not fail the steer — the message is already queued + // with Go and will reach the model; persistence is display-only. + try { + await appendCopilotChatMessages( + chatId, + [ + { + id: steeringId, + role: 'user', + content, + timestamp: new Date().toISOString(), + }, + ], + { streamId } + ) + } catch (err) { + logger.warn('Failed to persist steering message to chat history', { + chatId, + steeringId, + error: getErrorMessage(err), + }) + } + + rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.Queued) + logger.info('Queued mid-turn steering message', { + streamId, + chatId, + steeringId, + contentChars: content.length, + }) + return NextResponse.json({ ok: true, queued: true }) + } + ) +) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index c851c6cff2c..2f9a0bd4ca7 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -140,6 +140,14 @@ export const copilotChatAbortBodySchema = z.object({ }) export type CopilotChatAbortBody = z.input +export const copilotChatSteerBodySchema = z.object({ + streamId: z.string().min(1, 'streamId is required'), + chatId: z.string().min(1, 'chatId is required'), + steeringId: z.string().min(1, 'steeringId is required'), + content: z.string().min(1, 'content is required').max(32_768, 'content is too long'), +}) +export type CopilotChatSteerBody = z.input + export const copilotChatGetQuerySchema = z .object({ workflowId: z.string().optional(), diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index 76322f7b64c..f6ce59033ee 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -514,7 +514,13 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { type: 'object', }, MothershipStreamV1RunKind: { - enum: ['checkpoint_pause', 'resumed', 'compaction_start', 'compaction_done'], + enum: [ + 'checkpoint_pause', + 'resumed', + 'compaction_start', + 'compaction_done', + 'steering_applied', + ], type: 'string', }, MothershipStreamV1RunResumedEventEnvelope: { @@ -777,6 +783,26 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { enum: ['subagent', 'structured_result', 'subagent_result'], type: 'string', }, + MothershipStreamV1SteeringAppliedPayload: { + additionalProperties: false, + properties: { + content: { + type: 'string', + }, + kind: { + $ref: '#/$defs/MothershipStreamV1RunKind', + }, + messageId: { + type: 'string', + }, + mode: { + enum: ['deferred', 'interrupt'], + type: 'string', + }, + }, + required: ['kind', 'messageId', 'content', 'mode'], + type: 'object', + }, MothershipStreamV1StreamCursor: { additionalProperties: false, properties: { diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 5cdf7801bf8..7f4fbd98e19 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -468,12 +468,14 @@ export type MothershipStreamV1RunKind = | 'resumed' | 'compaction_start' | 'compaction_done' + | 'steering_applied' export const MothershipStreamV1RunKind = { checkpoint_pause: 'checkpoint_pause', resumed: 'resumed', compaction_start: 'compaction_start', compaction_done: 'compaction_done', + steering_applied: 'steering_applied', } as const export type MothershipStreamV1SessionKind = 'trace' | 'chat' | 'title' | 'start' diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts index 916ecc88569..8968f29522a 100644 --- a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts @@ -59,6 +59,14 @@ export const BillingRouteOutcome = { export type BillingRouteOutcomeKey = keyof typeof BillingRouteOutcome export type BillingRouteOutcomeValue = (typeof BillingRouteOutcome)[BillingRouteOutcomeKey] +export const ContextBudgetSource = { + Model: 'model', + PricingBoundary: 'pricing_boundary', +} as const + +export type ContextBudgetSourceKey = keyof typeof ContextBudgetSource +export type ContextBudgetSourceValue = (typeof ContextBudgetSource)[ContextBudgetSourceKey] + export const CopilotAbortOutcome = { BadRequest: 'bad_request', FallbackPersistFailed: 'fallback_persist_failed', @@ -204,6 +212,18 @@ export const CopilotSseCloseReason = { export type CopilotSseCloseReasonKey = keyof typeof CopilotSseCloseReason export type CopilotSseCloseReasonValue = (typeof CopilotSseCloseReason)[CopilotSseCloseReasonKey] +export const CopilotSteerOutcome = { + BadRequest: 'bad_request', + MissingContent: 'missing_content', + MissingMessageId: 'missing_message_id', + NoActiveTurn: 'no_active_turn', + QueueFull: 'queue_full', + Queued: 'queued', +} as const + +export type CopilotSteerOutcomeKey = keyof typeof CopilotSteerOutcome +export type CopilotSteerOutcomeValue = (typeof CopilotSteerOutcome)[CopilotSteerOutcomeKey] + export const CopilotStopOutcome = { ChatNotFound: 'chat_not_found', InternalError: 'internal_error', @@ -320,6 +340,40 @@ export const LlmErrorStage = { export type LlmErrorStageKey = keyof typeof LlmErrorStage export type LlmErrorStageValue = (typeof LlmErrorStage)[LlmErrorStageKey] +export const PromptComponent = { + ActiveSkills: 'active_skills', + AgentGuidance: 'agent_guidance', + Capabilities: 'capabilities', + Credentials: 'credentials', + DesktopContext: 'desktop_context', + ExternalTools: 'external_tools', + Files: 'files', + History: 'history', + Override: 'override', + PermissionsContext: 'permissions_context', + Persona: 'persona', + Policies: 'policies', + RuntimeTail: 'runtime_tail', + SessionContext: 'session_context', + SkillsIndex: 'skills_index', + SpawnContext: 'spawn_context', + Steering: 'steering', + SubagentDocs: 'subagent_docs', + SubagentRegistry: 'subagent_registry', + TaggedResources: 'tagged_resources', + TimeContext: 'time_context', + ToolDocs: 'tool_docs', + ToolResults: 'tool_results', + ToolsWire: 'tools_wire', + Vfs: 'vfs', + WorkflowContext: 'workflow_context', + WorkspaceGuide: 'workspace_guide', + WorkspaceInventory: 'workspace_inventory', +} as const + +export type PromptComponentKey = keyof typeof PromptComponent +export type PromptComponentValue = (typeof PromptComponent)[PromptComponentKey] + export const RateLimitOutcome = { Allowed: 'allowed', IncrError: 'incr_error', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 5a026a33c3a..c9d4233fc30 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -69,6 +69,7 @@ export const TraceAttr = { BillingInterval: 'billing.interval', BillingIsMcp: 'billing.is_mcp', BillingLlmCost: 'billing.llm_cost', + BillingLongContext: 'billing.long_context', BillingNewPlan: 'billing.new_plan', BillingOutcome: 'billing.outcome', BillingPlan: 'billing.plan', @@ -153,6 +154,7 @@ export const TraceAttr = { ConditionName: 'condition.name', ConditionResult: 'condition.result', ContextReduceBudgetChars: 'context.reduce.budget_chars', + ContextReduceBudgetSource: 'context.reduce.budget_source', ContextReduceCaller: 'context.reduce.caller', ContextReduceDidReduce: 'context.reduce.did_reduce', ContextReduceInputChars: 'context.reduce.input_chars', @@ -258,6 +260,11 @@ export const TraceAttr = { CopilotSseTerminalEventMissing: 'copilot.sse.terminal_event_missing', CopilotSseTerminalEventSeen: 'copilot.sse.terminal_event_seen', CopilotSseTotalDispatchMs: 'copilot.sse.total_dispatch_ms', + CopilotSteerOutcome: 'copilot.steer.outcome', + CopilotSteeringContentChars: 'copilot.steering.content_chars', + CopilotSteeringEntries: 'copilot.steering.entries', + CopilotSteeringMode: 'copilot.steering.mode', + CopilotSteeringSalvagedChars: 'copilot.steering.salvaged_chars', CopilotStopAppendedAssistant: 'copilot.stop.appended_assistant', CopilotStopBlocksCount: 'copilot.stop.blocks_count', CopilotStopContentLength: 'copilot.stop.content_length', @@ -452,8 +459,6 @@ export const TraceAttr = { HttpUrl: 'http.url', HttpUserAgent: 'http.user_agent', InvitationRole: 'invitation.role', - KnowledgeBaseId: 'knowledge_base.id', - KnowledgeBaseName: 'knowledge_base.name', LlmBackend: 'llm.backend', LlmCompactionPause: 'llm.compaction.pause', LlmErrorStage: 'llm.error_stage', @@ -473,6 +478,8 @@ export const TraceAttr = { LoopId: 'loop.id', LoopIterations: 'loop.iterations', LoopName: 'loop.name', + ManageKnowledgeBaseId: 'manage_knowledge_base.id', + ManageKnowledgeBaseName: 'manage_knowledge_base.name', McpExecutionStatus: 'mcp.execution_status', McpServerId: 'mcp.server_id', McpServerName: 'mcp.server_name', @@ -503,9 +510,36 @@ export const TraceAttr = { ProcessingChunkSize: 'processing.chunk_size', ProcessingRecipe: 'processing.recipe', PromptCacheableBlocks: 'prompt.cacheable_blocks', + PromptComponent: 'prompt.component', + PromptRegionFilesChars: 'prompt.region.files_chars', + PromptRegionFilesCount: 'prompt.region.files_count', + PromptRegionHistoryChars: 'prompt.region.history_chars', + PromptRegionHistoryMessages: 'prompt.region.history_messages', + PromptRegionRuntimeTailChars: 'prompt.region.runtime_tail_chars', + PromptRegionSteeringChars: 'prompt.region.steering_chars', + PromptRegionToolResultsChars: 'prompt.region.tool_results_chars', + PromptRegionVfsChars: 'prompt.region.vfs_chars', + PromptRegionVfsMessages: 'prompt.region.vfs_messages', + PromptRuntimeActiveSkillsChars: 'prompt.runtime.active_skills_chars', + PromptRuntimeTaggedResourcesChars: 'prompt.runtime.tagged_resources_chars', + PromptSectionAgentGuidanceChars: 'prompt.section.agent_guidance_chars', + PromptSectionCapabilitiesChars: 'prompt.section.capabilities_chars', + PromptSectionCredentialsChars: 'prompt.section.credentials_chars', + PromptSectionOverrideChars: 'prompt.section.override_chars', + PromptSectionPersonaChars: 'prompt.section.persona_chars', + PromptSectionPoliciesChars: 'prompt.section.policies_chars', + PromptSectionSkillsIndexChars: 'prompt.section.skills_index_chars', + PromptSectionSpawnContextChars: 'prompt.section.spawn_context_chars', + PromptSectionSubagentDocsChars: 'prompt.section.subagent_docs_chars', + PromptSectionToolDocsChars: 'prompt.section.tool_docs_chars', + PromptSectionWorkspaceGuideChars: 'prompt.section.workspace_guide_chars', + PromptSectionWorkspaceInventoryChars: 'prompt.section.workspace_inventory_chars', PromptSet: 'prompt.set', + PromptSite: 'prompt.site', PromptSystemBlocks: 'prompt.system_blocks', PromptSystemChars: 'prompt.system_chars', + PromptToolsWireChars: 'prompt.tools.wire_chars', + PromptToolsWireCount: 'prompt.tools.wire_count', ProviderId: 'provider.id', RateLimitAttempt: 'rate_limit.attempt', RateLimitCount: 'rate_limit.count', @@ -714,6 +748,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'billing.interval', 'billing.is_mcp', 'billing.llm_cost', + 'billing.long_context', 'billing.new_plan', 'billing.outcome', 'billing.plan', @@ -798,6 +833,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'condition.name', 'condition.result', 'context.reduce.budget_chars', + 'context.reduce.budget_source', 'context.reduce.caller', 'context.reduce.did_reduce', 'context.reduce.input_chars', @@ -903,6 +939,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.sse.terminal_event_missing', 'copilot.sse.terminal_event_seen', 'copilot.sse.total_dispatch_ms', + 'copilot.steer.outcome', + 'copilot.steering.content_chars', + 'copilot.steering.entries', + 'copilot.steering.mode', + 'copilot.steering.salvaged_chars', 'copilot.stop.appended_assistant', 'copilot.stop.blocks_count', 'copilot.stop.content_length', @@ -1086,8 +1127,6 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'http.url', 'http.user_agent', 'invitation.role', - 'knowledge_base.id', - 'knowledge_base.name', 'llm.backend', 'llm.compaction.pause', 'llm.error_stage', @@ -1107,6 +1146,8 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'loop.id', 'loop.iterations', 'loop.name', + 'manage_knowledge_base.id', + 'manage_knowledge_base.name', 'mcp.execution_status', 'mcp.server_id', 'mcp.server_name', @@ -1137,9 +1178,36 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'processing.chunk_size', 'processing.recipe', 'prompt.cacheable_blocks', + 'prompt.component', + 'prompt.region.files_chars', + 'prompt.region.files_count', + 'prompt.region.history_chars', + 'prompt.region.history_messages', + 'prompt.region.runtime_tail_chars', + 'prompt.region.steering_chars', + 'prompt.region.tool_results_chars', + 'prompt.region.vfs_chars', + 'prompt.region.vfs_messages', + 'prompt.runtime.active_skills_chars', + 'prompt.runtime.tagged_resources_chars', + 'prompt.section.agent_guidance_chars', + 'prompt.section.capabilities_chars', + 'prompt.section.credentials_chars', + 'prompt.section.override_chars', + 'prompt.section.persona_chars', + 'prompt.section.policies_chars', + 'prompt.section.skills_index_chars', + 'prompt.section.spawn_context_chars', + 'prompt.section.subagent_docs_chars', + 'prompt.section.tool_docs_chars', + 'prompt.section.workspace_guide_chars', + 'prompt.section.workspace_inventory_chars', 'prompt.set', + 'prompt.site', 'prompt.system_blocks', 'prompt.system_chars', + 'prompt.tools.wire_chars', + 'prompt.tools.wire_count', 'provider.id', 'rate_limit.attempt', 'rate_limit.count', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index 5048cb2fbf7..eccf2fd94f0 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -53,6 +53,7 @@ export const TraceSpan = { CopilotChatResolveAgentContexts: 'copilot.chat.resolve_agent_contexts', CopilotChatResolveBranch: 'copilot.chat.resolve_branch', CopilotChatResolveOrCreateChat: 'copilot.chat.resolve_or_create_chat', + CopilotChatSteerStream: 'copilot.chat.steer_stream', CopilotChatStopStream: 'copilot.chat.stop_stream', CopilotConfirmToolResult: 'copilot.confirm.tool_result', CopilotFinalizeStream: 'copilot.finalize_stream', @@ -128,6 +129,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.chat.resolve_agent_contexts', 'copilot.chat.resolve_branch', 'copilot.chat.resolve_or_create_chat', + 'copilot.chat.steer_stream', 'copilot.chat.stop_stream', 'copilot.confirm.tool_result', 'copilot.finalize_stream', diff --git a/apps/sim/lib/copilot/request/session/steer.ts b/apps/sim/lib/copilot/request/session/steer.ts new file mode 100644 index 00000000000..4e63afca65e --- /dev/null +++ b/apps/sim/lib/copilot/request/session/steer.ts @@ -0,0 +1,79 @@ +import type { Context } from '@opentelemetry/api' +import { + COPILOT_BILLING_PROTOCOL, + COPILOT_BILLING_PROTOCOL_HEADER, +} from '@/lib/billing/core/billing-attribution' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' +import { env } from '@/lib/core/config/env' + +export const DEFAULT_STEER_TIMEOUT_MS = 3000 + +/** + * Queues a mid-turn steering message with the Go side (`/api/streams/steer`). + * + * Acceptance means "queued", not "applied": Go acknowledges application with a + * `run`/`steering_applied` stream event carrying the steeringId. A caller that + * never sees that ack before the stream ends must re-send the content as an + * ordinary message — that contract is what makes delivery loss-free without + * this call having to prove stream liveness. + */ +export async function requestStreamSteering(params: { + streamId: string + userId: string + chatId: string + steeringId: string + content: string + workspaceId?: string + timeoutMs?: number + otelContext?: Context +}): Promise<{ queued: boolean; status: number }> { + const { + streamId, + userId, + chatId, + steeringId, + content, + timeoutMs = DEFAULT_STEER_TIMEOUT_MS, + otelContext, + } = params + + const headers: Record = { + 'Content-Type': 'application/json', + [COPILOT_BILLING_PROTOCOL_HEADER]: COPILOT_BILLING_PROTOCOL.legacy, + } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + Object.assign(headers, getMothershipSourceEnvHeaders()) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort('steer_fetch_timeout'), timeoutMs) + + try { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const response = await fetchGo(`${mothershipBaseURL}/api/streams/steer`, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ + messageId: streamId, + userId, + chatId, + steeringId, + content, + }), + otelContext, + spanName: 'sim → go /api/streams/steer', + operation: 'steer', + attributes: { + [TraceAttr.StreamId]: streamId, + [TraceAttr.ChatId]: chatId, + }, + }) + return { queued: response.ok, status: response.status } + } finally { + clearTimeout(timeout) + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 58393603987..ba484e5faf6 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1105, - zodRoutes: 1105, + totalRoutes: 1106, + zodRoutes: 1106, nonZodRoutes: 0, } as const From 2f6e4cab93723bf901e0c4ceec8206f81be07b4b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:05:11 -0700 Subject: [PATCH 018/135] Sync generated contracts for async subagent orchestration Pulls the mothership tool catalog (wait_agents / tail_agent / steer_agent / interrupt_agent), trace spans (chat.async_subagent.*, chat.orchestrate.*), and trace attributes (copilot.async_subagent.*) into the generated TS contracts. --- .../lib/copilot/generated/tool-catalog-v1.ts | 97 +++++++++++++++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 81 ++++++++++++++++ .../copilot/generated/trace-attributes-v1.ts | 18 ++++ .../lib/copilot/generated/trace-spans-v1.ts | 14 +++ 4 files changed, 210 insertions(+) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 7648644e3f1..da266e91fd0 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -63,6 +63,7 @@ export interface ToolCatalogEntry { | 'get_workflow_run_options' | 'glob' | 'grep' + | 'interrupt_agent' | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' @@ -111,6 +112,7 @@ export interface ToolCatalogEntry { | 'set_environment_variables' | 'set_global_workflow_variables' | 'share_file' + | 'steer_agent' | 'table' | 'table_automations' | 'table_columns' @@ -118,11 +120,13 @@ export interface ToolCatalogEntry { | 'table_manage' | 'table_rows' | 'table_views' + | 'tail_agent' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'wait_agents' | 'web_crawl' | 'web_fetch' | 'web_scrape' @@ -187,6 +191,7 @@ export interface ToolCatalogEntry { | 'get_workflow_run_options' | 'glob' | 'grep' + | 'interrupt_agent' | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' @@ -235,6 +240,7 @@ export interface ToolCatalogEntry { | 'set_environment_variables' | 'set_global_workflow_variables' | 'share_file' + | 'steer_agent' | 'table' | 'table_automations' | 'table_columns' @@ -242,11 +248,13 @@ export interface ToolCatalogEntry { | 'table_manage' | 'table_rows' | 'table_views' + | 'tail_agent' | 'terminal' | 'update_deployment_version' | 'update_workspace_mcp_server' | 'user_table' | 'wait' + | 'wait_agents' | 'web_crawl' | 'web_fetch' | 'web_scrape' @@ -3131,6 +3139,25 @@ export const Grep: ToolCatalogEntry = { }, } +export const InterruptAgent: ToolCatalogEntry = { + id: 'interrupt_agent', + name: 'interrupt_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to interrupt.' }, + reason: { + type: 'string', + description: + "Why you are stopping it, in a few words. Recorded in the agent's final status.", + }, + }, + required: ['agent_id'], + }, +} + export const Knowledge: ToolCatalogEntry = { id: 'knowledge', name: 'knowledge', @@ -5293,6 +5320,24 @@ export const ShareFile: ToolCatalogEntry = { requiredPermission: 'write', } +export const SteerAgent: ToolCatalogEntry = { + id: 'steer_agent', + name: 'steer_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to steer.' }, + content: { + type: 'string', + description: 'The instruction to deliver, phrased as you would brief a teammate mid-task.', + }, + }, + required: ['agent_id', 'content'], + }, +} + export const Table: ToolCatalogEntry = { id: 'table', name: 'table', @@ -5879,6 +5924,25 @@ export const TableViews: ToolCatalogEntry = { }, } +export const TailAgent: ToolCatalogEntry = { + id: 'tail_agent', + name: 'tail_agent', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_id: { type: 'string', description: 'The agent id to inspect.' }, + max_chars: { + type: 'number', + description: + 'Max characters of activity to return. Default 4000; unread activity beyond the budget stays queued for your next tail.', + }, + }, + required: ['agent_id'], + }, +} + export const Terminal: ToolCatalogEntry = { id: 'terminal', name: 'terminal', @@ -6412,6 +6476,35 @@ export const Wait: ToolCatalogEntry = { }, } +export const WaitAgents: ToolCatalogEntry = { + id: 'wait_agents', + name: 'wait_agents', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + agent_ids: { + type: 'array', + description: 'The agent ids to wait on, as returned by their async launches.', + items: { type: 'string' }, + }, + mode: { + type: 'string', + description: + '"all" (default) wakes when every listed agent finishes; "any" wakes on the first.', + enum: ['all', 'any'], + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to sleep before waking anyway. Default 120, capped at 600. On timeout you get current statuses and can wait again.', + }, + }, + required: ['agent_ids'], + }, +} + export const WebCrawl: ToolCatalogEntry = { id: 'web_crawl', name: 'web_crawl', @@ -7038,6 +7131,7 @@ export const TOOL_CATALOG: Record = { [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, + [InterruptAgent.id]: InterruptAgent, [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, @@ -7086,6 +7180,7 @@ export const TOOL_CATALOG: Record = { [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, + [SteerAgent.id]: SteerAgent, [Table.id]: Table, [TableAutomations.id]: TableAutomations, [TableColumns.id]: TableColumns, @@ -7093,11 +7188,13 @@ export const TOOL_CATALOG: Record = { [TableManage.id]: TableManage, [TableRows.id]: TableRows, [TableViews.id]: TableViews, + [TailAgent.id]: TailAgent, [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, [Wait.id]: Wait, + [WaitAgents.id]: WaitAgents, [WebCrawl.id]: WebCrawl, [WebFetch.id]: WebFetch, [WebScrape.id]: WebScrape, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index a75d3ddd632..105022d971a 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3082,6 +3082,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + interrupt_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to interrupt.', + }, + reason: { + type: 'string', + description: + "Why you are stopping it, in a few words. Recorded in the agent's final status.", + }, + }, + required: ['agent_id'], + }, + resultSchema: undefined, + }, knowledge: { parameters: { properties: { @@ -5210,6 +5228,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + steer_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to steer.', + }, + content: { + type: 'string', + description: + 'The instruction to deliver, phrased as you would brief a teammate mid-task.', + }, + }, + required: ['agent_id', 'content'], + }, + resultSchema: undefined, + }, table: { parameters: { properties: { @@ -5878,6 +5914,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + tail_agent: { + parameters: { + type: 'object', + properties: { + agent_id: { + type: 'string', + description: 'The agent id to inspect.', + }, + max_chars: { + type: 'number', + description: + 'Max characters of activity to return. Default 4000; unread activity beyond the budget stays queued for your next tail.', + }, + }, + required: ['agent_id'], + }, + resultSchema: undefined, + }, terminal: { parameters: { type: 'object', @@ -6439,6 +6493,33 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + wait_agents: { + parameters: { + type: 'object', + properties: { + agent_ids: { + type: 'array', + description: 'The agent ids to wait on, as returned by their async launches.', + items: { + type: 'string', + }, + }, + mode: { + type: 'string', + description: + '"all" (default) wakes when every listed agent finishes; "any" wakes on the first.', + enum: ['all', 'any'], + }, + timeout_seconds: { + type: 'number', + description: + 'Max seconds to sleep before waking anyway. Default 120, capped at 600. On timeout you get current statuses and can wait again.', + }, + }, + required: ['agent_ids'], + }, + resultSchema: undefined, + }, web_crawl: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index c9d4233fc30..8e8778eb6d0 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -179,6 +179,15 @@ export const TraceAttr = { CopilotAbortMarkerWritten: 'copilot.abort.marker_written', CopilotAbortOutcome: 'copilot.abort.outcome', CopilotAbortUnknownReason: 'copilot.abort.unknown_reason', + CopilotAsyncSubagentAgent: 'copilot.async_subagent.agent', + CopilotAsyncSubagentCount: 'copilot.async_subagent.count', + CopilotAsyncSubagentDeltaChars: 'copilot.async_subagent.delta_chars', + CopilotAsyncSubagentId: 'copilot.async_subagent.id', + CopilotAsyncSubagentReminders: 'copilot.async_subagent.reminders', + CopilotAsyncSubagentSleptMs: 'copilot.async_subagent.slept_ms', + CopilotAsyncSubagentStatus: 'copilot.async_subagent.status', + CopilotAsyncSubagentWaitMode: 'copilot.async_subagent.wait_mode', + CopilotAsyncSubagentWakeReason: 'copilot.async_subagent.wake_reason', CopilotAsyncToolClaimedBy: 'copilot.async_tool.claimed_by', CopilotAsyncToolHasError: 'copilot.async_tool.has_error', CopilotAsyncToolIdsCount: 'copilot.async_tool.ids_count', @@ -858,6 +867,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.abort.marker_written', 'copilot.abort.outcome', 'copilot.abort.unknown_reason', + 'copilot.async_subagent.agent', + 'copilot.async_subagent.count', + 'copilot.async_subagent.delta_chars', + 'copilot.async_subagent.id', + 'copilot.async_subagent.reminders', + 'copilot.async_subagent.slept_ms', + 'copilot.async_subagent.status', + 'copilot.async_subagent.wait_mode', + 'copilot.async_subagent.wake_reason', 'copilot.async_tool.claimed_by', 'copilot.async_tool.has_error', 'copilot.async_tool.ids_count', diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index eccf2fd94f0..ac8665f8d22 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -12,6 +12,9 @@ export const TraceSpan = { AsyncToolStoreSet: 'async_tool_store.set', AuthRateLimitRecord: 'auth.rate_limit.record', AuthValidateKey: 'auth.validate_key', + ChatAsyncSubagentRun: 'chat.async_subagent.run', + ChatAsyncSubagentShutdown: 'chat.async_subagent.shutdown', + ChatAsyncSubagentSpawn: 'chat.async_subagent.spawn', ChatContinueWithToolResults: 'chat.continue_with_tool_results', ChatExplicitAbortConsume: 'chat.explicit_abort.consume', ChatExplicitAbortFlushPausedBilling: 'chat.explicit_abort.flush_paused_billing', @@ -19,6 +22,10 @@ export const TraceSpan = { ChatExplicitAbortMark: 'chat.explicit_abort.mark', ChatExplicitAbortPeek: 'chat.explicit_abort.peek', ChatGateAcquire: 'chat.gate.acquire', + ChatOrchestrateInterrupt: 'chat.orchestrate.interrupt', + ChatOrchestrateSteer: 'chat.orchestrate.steer', + ChatOrchestrateTail: 'chat.orchestrate.tail', + ChatOrchestrateWait: 'chat.orchestrate.wait', ChatPersistAfterDone: 'chat.persist_after_done', ChatSetup: 'chat.setup', ContextReduce: 'context.reduce', @@ -88,6 +95,9 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'async_tool_store.set', 'auth.rate_limit.record', 'auth.validate_key', + 'chat.async_subagent.run', + 'chat.async_subagent.shutdown', + 'chat.async_subagent.spawn', 'chat.continue_with_tool_results', 'chat.explicit_abort.consume', 'chat.explicit_abort.flush_paused_billing', @@ -95,6 +105,10 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'chat.explicit_abort.mark', 'chat.explicit_abort.peek', 'chat.gate.acquire', + 'chat.orchestrate.interrupt', + 'chat.orchestrate.steer', + 'chat.orchestrate.tail', + 'chat.orchestrate.wait', 'chat.persist_after_done', 'chat.setup', 'context.reduce', From 7f9f11f6dda617b8c4637775f7c815378f1fca6c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:08:32 -0700 Subject: [PATCH 019/135] Add display titles for the async subagent orchestration tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait_agents / tail_agent / steer_agent / interrupt_agent get natural-language running titles (naming the agent id being waited on, tailed, steered, or stopped) and a Steering→Steered completed-verb rewrite. --- apps/sim/lib/copilot/tools/tool-display.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 5981910cd0a..8f1fcc83437 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -609,6 +609,15 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } +/** Title for a wait_agents sleep, naming the agent(s) being collected. */ +function waitAgentsTitle(args: ToolArgs): string { + const raw = args?.agent_ids + const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + if (ids.length === 1) return `Waiting for ${ids[0]}` + if (ids.length > 1) return `Waiting for ${ids.length} agents` + return 'Waiting for agents' +} + /** * The title of a pause that is still running, counting down what is left. * @@ -714,6 +723,14 @@ export function getToolDisplayTitle(name: string, args?: Record return openResourceTitle(args) case 'wait': return waitTitle(args) + case 'wait_agents': + return waitAgentsTitle(args) + case 'tail_agent': + return `Checking on ${stringArg(args, 'agent_id') || 'agent'}` + case 'steer_agent': + return `Steering ${stringArg(args, 'agent_id') || 'agent'}` + case 'interrupt_agent': + return `Stopping ${stringArg(args, 'agent_id') || 'agent'}` case 'terminal': return terminalTitle(args) // The surface used to be one tool per operation. Conversations recorded @@ -1066,6 +1083,7 @@ const COMPLETED_VERB_REWRITES: Record = { Selecting: 'Selected', Setting: 'Set', Sharing: 'Shared', + Steering: 'Steered', Stopping: 'Stopped', Summarizing: 'Summarized', Switching: 'Switched', From 4ad9f17ecb0ed6a244bc9d91f9b5b1b2f317d3ec Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:16:46 -0700 Subject: [PATCH 020/135] Show orchestrator-chosen subagent names on agent groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subagent_start whose payload data carries a name (the orchestrator's new name trigger parameter) now labels the agent group with that mission name — the agent-type icon stays. The name flows through the live stream path, the turn model (AgentNode.displayName) and its serialize/rebuild round-trip, and persisted transcripts (PersistedContentBlock.name), so reloads keep the label. --- .../message-content/message-content.tsx | 2 ++ .../home/hooks/stream/turn-model-serialize.ts | 6 ++++- .../home/hooks/stream/turn-model.test.ts | 23 +++++++++++++++++++ .../home/hooks/stream/turn-model.ts | 5 ++++ .../app/workspace/[workspaceId]/home/types.ts | 2 ++ apps/sim/lib/copilot/chat/display-message.ts | 6 ++++- .../sim/lib/copilot/chat/persisted-message.ts | 8 +++++++ apps/sim/lib/copilot/request/go/stream.ts | 8 +++++++ apps/sim/lib/copilot/request/types.ts | 2 ++ 9 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 24dfc3fd842..e16282ea66b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -380,6 +380,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content] if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) + if (block.subagentName) g.agentLabel = block.subagentName if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -623,6 +624,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { } groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) + if (block.subagentName) g.agentLabel = block.subagentName if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index d82bb65783b..6088ee0419a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -167,6 +167,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { block: { type: 'subagent', content: node.agentId, + ...(node.displayName ? { subagentName: node.displayName } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -267,7 +268,10 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { kind: 'subagent', event: 'start', agent: block.content, - data: block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}, + data: { + ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), + ...(block.subagentName ? { name: block.subagentName } : {}), + }, }, scopeFor(block), block.timestamp diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 1c5eb2d72d7..85c0b420e73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -237,6 +237,29 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').parentSpanId).toBe(MAIN_SPAN) }) + it('captures the orchestrator-chosen display name from span start data', () => { + const m = apply([ + envelope( + 1, + 'span', + { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-r', name: 'Pricing research' }, + }, + { + lane: 'subagent', + spanId: 'S1', + parentSpanId: MAIN_SPAN, + parentToolCallId: 'tc-r', + agentId: 'research', + } + ), + ]) + expect(agent(m, 'S1').displayName).toBe('Pricing research') + }) + it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 25651a0eb59..490f86d0109 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -85,6 +85,8 @@ export interface AgentNode extends NodeBase { agentId: string /** The outer delegation tool_use that triggered this run; links the trigger tool node. */ triggerToolCallId?: string + /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ + displayName?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -563,6 +565,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const triggerToolCallId = scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' + const displayName = asString(data?.name) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -581,6 +584,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // scope.agentId can name the forwarding caller (e.g. superagent), // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId + if (displayName && !existing.displayName) existing.displayName = displayName if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -602,6 +606,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq: seq, ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), + ...(displayName ? { displayName } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index f29c7b86125..eedb402ba87 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -114,6 +114,8 @@ export interface ContentBlock { type: ContentBlockType content?: string subagent?: string + /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ + subagentName?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 443d517a7bb..28a348a5837 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -92,7 +92,11 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) { return { type: ContentBlockType.subagent_end } } - return { type: ContentBlockType.subagent, content: block.content } + return { + type: ContentBlockType.subagent, + content: block.content, + ...(block.name ? { subagentName: block.name } : {}), + } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { return { type: ContentBlockType.stopped } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 76372f25d9e..c57e49b5a85 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -53,6 +53,8 @@ export interface PersistedContentBlock { lifecycle?: MothershipStreamV1SpanLifecycleEvent status?: MothershipStreamV1CompletionStatus content?: string + /** Orchestrator-chosen display name on a subagent start block. */ + name?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -245,6 +247,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { kind: MothershipStreamV1SpanPayloadKind.subagent, lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, + ...(block.subagentName ? { name: block.subagentName } : {}), } case 'subagent_text': return { @@ -436,6 +439,9 @@ interface RawBlock { type: string lane?: string agent?: string + /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ + name?: string + subagentName?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -503,6 +509,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { result.lane = block.lane } if (block.agent) result.agent = block.agent + if (block.name) result.name = block.name const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -584,6 +591,7 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { kind: MothershipStreamV1SpanPayloadKind.subagent, lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, + ...(block.subagentName ? { name: block.subagentName } : {}), } } diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index ab924af5e67..e72bb29d0a0 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -436,9 +436,17 @@ export async function runStreamLoop( const openParents = (context.openSubagentParents ??= new Set()) if (!openParents.has(toolCallId)) { openParents.add(toolCallId) + const payloadData = streamEvent.payload.data + const displayName = + payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) + ? (payloadData as Record).name + : undefined context.contentBlocks.push({ type: 'subagent', content: subagentName, + ...(typeof displayName === 'string' && displayName + ? { subagentName: displayName } + : {}), parentToolCallId: toolCallId, ...(spanId ? { spanId } : {}), ...(parentSpanId ? { parentSpanId } : {}), diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 580b17238ff..35127f3edf9 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -80,6 +80,8 @@ export interface ContentBlock { * `subagent` start block is missing (resume legs re-emit text without start). */ subagent?: string + /** Orchestrator-chosen display name for a `subagent` start block. */ + subagentName?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run From ba788296b016ba1864dc4aea0114dd3bd3cc4d55 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:18:27 -0700 Subject: [PATCH 021/135] Improve Copilot error handling and logging --- .../[workspaceId]/home/hooks/use-chat.ts | 34 ++++++++++- apps/sim/instrumentation-node.ts | 33 +++++++++++ apps/sim/lib/copilot/application/error.ts | 16 ++++- .../client/browser-tool-execution.test.ts | 35 +++++++++++ .../tools/client/browser-tool-execution.ts | 30 ++++++++++ .../lib/copilot/tools/client/completion.ts | 13 ++++- .../tools/client/run-tool-execution.test.ts | 18 ++++-- .../tools/handlers/function-execute.test.ts | 12 ++-- .../tools/handlers/function-execute.ts | 10 +++- .../tools/registry/server-tool-adapter.ts | 2 + .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 33 +++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 8 ++- apps/sim/package.json | 2 + bun.lock | 3 + packages/logger/package.json | 1 + packages/logger/src/index.ts | 58 +++++++++++++++++++ 16 files changed, 289 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2bef849721d..2f431e3c354 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -228,6 +228,7 @@ const RECONNECT_TAIL_ERROR = const MAX_RECONNECT_ATTEMPTS = 10 const RECONNECT_BASE_DELAY_MS = 1000 const RECONNECT_MAX_DELAY_MS = 30_000 +const RECONNECT_EXHAUSTED_RECHECK_MS = 30_000 const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000 const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000 const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000 @@ -1470,6 +1471,10 @@ export function useChat( () => {} ) const recoveringQueuedSendHandoffRef = useRef(null) + const recoverActiveStreamRef = useRef< + (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck') => Promise + >(async () => {}) + const reconnectExhaustedRecheckTimerRef = useRef | null>(null) const abortControllerRef = useRef(null) const detachedChatResolutionControllersRef = useRef>(new Set()) @@ -3242,7 +3247,29 @@ export function useChat( maxAttempts: MAX_RECONNECT_ATTEMPTS, }) if (streamGenRef.current === gen) { + /** + * Never give up silently: surface the failure so the pane shows why + * the live stream stopped instead of a torn-down transcript. Callers + * own the finalize on a false return (every call site finalizes with + * error: true), which refetches the persisted transcript; if the + * server turn is still running, the visibility/online recovery path + * re-attaches on the next pageshow/visible/online event. + */ setIsReconnecting(false) + setError(RECONNECT_TAIL_ERROR) + /** + * The tab may stay visible (no pageshow/visible/online event will ever + * fire) while the server turn keeps running detached. One bounded + * recheck re-enters recovery once the transient network condition has + * had time to clear; recovery itself no-ops when nothing is active. + */ + if (reconnectExhaustedRecheckTimerRef.current) { + clearTimeout(reconnectExhaustedRecheckTimerRef.current) + } + reconnectExhaustedRecheckTimerRef.current = setTimeout(() => { + reconnectExhaustedRecheckTimerRef.current = null + void recoverActiveStreamRef.current('exhausted_recheck') + }, RECONNECT_EXHAUSTED_RECHECK_MS) } return false }, @@ -3251,7 +3278,7 @@ export function useChat( retryReconnectRef.current = retryReconnect const recoverActiveStreamFromRedis = useCallback( - async (reason: 'pageshow' | 'visible' | 'online'): Promise => { + async (reason: 'pageshow' | 'visible' | 'online' | 'exhausted_recheck'): Promise => { const startingChatId = chatIdRef.current const startingSelectedChatId = selectedChatIdRef.current const chatId = startingChatId ?? startingSelectedChatId @@ -3386,6 +3413,7 @@ export function useChat( }, [getActiveStreamIdForChat, queryClient, resumeOrFinalize, setTransportReconnecting] ) + recoverActiveStreamRef.current = recoverActiveStreamFromRedis useEffect(() => { if (typeof window === 'undefined' || typeof document === 'undefined') return @@ -3417,6 +3445,10 @@ export function useChat( document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('pageshow', handlePageShow) window.removeEventListener('online', handleOnline) + if (reconnectExhaustedRecheckTimerRef.current) { + clearTimeout(reconnectExhaustedRecheckTimerRef.current) + reconnectExhaustedRecheckTimerRef.current = null + } } }, [recoverActiveStreamFromRedis]) diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index 9d536bb5276..2b9da2a012c 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -83,6 +83,23 @@ function normalizeOtlpMetricsUrl(url: string): string { } } +// Logs counterpart to `normalizeOtlpMetricsUrl` — same parsed-pathname +// handling, targeting the /v1/logs signal path. +function normalizeOtlpLogsUrl(url: string): string { + if (!url) return url + try { + const u = new URL(url) + const path = u.pathname.replace(/\/$/, '') + if (path.endsWith('/v1/logs')) return url + u.pathname = path.endsWith('/v1/traces') + ? path.replace(/\/v1\/traces$/, '/v1/logs') + : `${path}/v1/logs` + return u.toString() + } catch { + return url + } +} + // deployment.environment in the GO value space (dev | staging | prod) without // any new infra env var. Every deployed Sim tier already gets // APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we @@ -177,6 +194,8 @@ async function initializeOpenTelemetry() { const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http') const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http') const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics') + const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-http') + const { BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs') const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node') const { TraceIdRatioBasedSampler, SamplingDecision } = await import( '@opentelemetry/sdk-trace-base' @@ -271,6 +290,19 @@ async function initializeOpenTelemetry() { exportIntervalMillis: 60000, }) + // Logs share the trace endpoint and headers as well (signal path + // /v1/logs). Every @sim/logger line fans out through the global Logs API + // (see packages/logger), which the NodeSDK wires to this processor — the + // stdout JSON lines continue to CloudWatch unchanged. + const logRecordProcessor = new BatchLogRecordProcessor( + new OTLPLogExporter({ + url: normalizeOtlpLogsUrl(telemetryConfig.endpoint), + headers: otlpHeaders, + timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000), + keepAlive: false, + }) + ) + // Must be unique per process: replicas sharing one instance id collapse // into a single Prometheus series, so their independent cumulative // counters interleave and corrupt rate()/increase(). The slug keeps Sim @@ -320,6 +352,7 @@ async function initializeOpenTelemetry() { spanProcessors, sampler, metricReader, + logRecordProcessors: [logRecordProcessor], }) sdk.start() diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts index 966a3233b7c..1626fb942b2 100644 --- a/apps/sim/lib/copilot/application/error.ts +++ b/apps/sim/lib/copilot/application/error.ts @@ -1,13 +1,25 @@ +import { trace } from '@opentelemetry/api' +import { toError } from '@sim/utils/errors' import { asOrchestrationError } from '@/lib/core/orchestration/types' export const COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE = 'The operation failed due to a system error. Please retry.' -/** Projects only caller-actionable application failures into Copilot-visible content. */ +/** + * Projects only caller-actionable application failures into Copilot-visible + * content. Whenever the real cause is swallowed by the generic fallback, it is + * recorded on the active span first — otherwise these failures are + * undiagnosable from telemetry (the cause otherwise lives only in stdout logs + * that do not ship anywhere queryable). + */ export function messageForCopilotApplicationError( error: unknown, fallback = COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE ): string { const classified = asOrchestrationError(error) - return classified && classified.code !== 'internal' ? classified.message : fallback + if (classified && classified.code !== 'internal') { + return classified.message + } + trace.getActiveSpan()?.recordException(toError(error)) + return fallback } diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts index 8731aafeedc..82b9cc74da1 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts @@ -436,3 +436,38 @@ describe('executeBrowserToolOnClient', () => { }) }) }) + +describe('pre-dispatch drops still resolve the waiter', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReportCompletion.mockResolvedValue(undefined) + }) + + it('reports an error confirmation for a stale event instead of hanging the turn', async () => { + const staleTs = new Date(Date.now() - 10 * 60 * 1000).toISOString() + executeBrowserToolOnClient('stale-call-1', 'browser_list_sessions', {}, 'chat-scope-1', staleTs) + await sleep(0) + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + 'stale-call-1', + 'error', + expect.stringContaining('too late'), + expect.objectContaining({ staleEvent: true }) + ) + }) + + it('reports an error confirmation when no chat scope exists', async () => { + useBrowserSessionStore.setState({ activeScopeId: null }) + executeBrowserToolOnClient('no-scope-1', 'browser_list_sessions', {}, undefined) + await sleep(0) + + expect(mockExecuteBrowserTool).not.toHaveBeenCalled() + expect(mockReportCompletion).toHaveBeenCalledWith( + 'no-scope-1', + 'error', + expect.stringContaining('no active browser session'), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts index e0a13d08125..4e1f4476822 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts @@ -175,15 +175,45 @@ export function executeBrowserToolOnClient( ): void { if (!scopeId) { logger.error('Cannot execute browser tool without a chat scope', { toolCallId, toolName }) + // Tell the waiter, or the turn hangs forever on a tool that never ran. + const message = 'This browser action could not run: no active browser session for this chat.' + void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + }).catch((reportErr) => { + logger.error('Failed to report missing-scope browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) return } if (hasAlreadyExecuted(toolCallId)) { + // Same-page re-delivery: the original dispatch is in flight (or done) and + // owns the result. Reporting here would race it — the server claims each + // resume exactly once, so an error now would discard the genuine result. logger.info('Skipping already-executed browser tool (replay)', { toolCallId, toolName }) return } const age = eventAgeMs(eventTs) if (age !== null && age > MAX_EVENT_AGE_MS) { logger.info('Skipping stale browser tool event', { toolCallId, toolName, age }) + // Usually a replay of an action that already ran and resumed in a previous + // page lifetime — the server claims each resume exactly once, so this + // duplicate confirmation is simply discarded. When it is NOT a replay + // (the event was delivered late, e.g. a backgrounded tab with throttled + // timers), this error unblocks the turn instead of leaving it hanging + // forever on a tool that will never execute. + const message = + 'This browser action was delivered too late to run safely. Ask again to retry it.' + void reportClientToolCompletion(toolCallId, ASYNC_TOOL_CONFIRMATION_STATUS.error, message, { + error: message, + staleEvent: true, + }).catch((reportErr) => { + logger.error('Failed to report stale browser tool error', { + toolCallId, + error: toError(reportErr).message, + }) + }) return } markExecuted(toolCallId) diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/copilot/tools/client/completion.ts index b99cb55cf98..691cb1699f4 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/copilot/tools/client/completion.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { isRecordLike } from '@sim/utils/object' +import { backoffWithJitter } from '@sim/utils/retry' import type { AsyncCompletionData, AsyncConfirmationStatus, @@ -48,7 +49,13 @@ export async function reportClientToolCompletion( const bodySize = new Blob([body]).size let lastError: Error | null = null - for (let attempt = 1; attempt <= 2; attempt++) { + // A lost confirmation strands the server-side waiter forever (the turn shows + // the tool as running indefinitely), so ride out multi-second network blips: + // 5 attempts with jittered exponential backoff (~15s total) instead of a + // sub-second give-up. The confirm endpoint claims each resume exactly once, + // so duplicate deliveries from retries are discarded server-side. + const maxAttempts = 5 + for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await send(body) if (response.ok) return @@ -78,8 +85,8 @@ export async function reportClientToolCompletion( lastError = toError(error) } - if (attempt < 2) { - await sleep(250) + if (attempt < maxAttempts) { + await sleep(backoffWithJitter(attempt, null)) } } diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts index ccf8c55de2d..95e58fa4640 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts @@ -57,6 +57,11 @@ const setCurrentExecutionId = vi.fn() const getCurrentExecutionId = vi.fn() const getWorkflowExecution = vi.fn(() => ({ isExecuting: false })) +// Neutralize the confirm-retry backoff so exhaustion tests stay fast. +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: () => 0, +})) + vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({ executeWorkflowWithFullLogging, })) @@ -265,6 +270,9 @@ describe('run tool execution cancellation', () => { }) .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) .mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) @@ -273,7 +281,7 @@ describe('run tool execution cancellation', () => { async: true, }) - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(6)) await vi.waitFor(() => expect(isRunToolActiveForId('tool-recover-async')).toBe(false)) loadExecutionPointer.mockResolvedValueOnce({ workflowId: 'wf-1', @@ -283,10 +291,10 @@ describe('run tool execution cancellation', () => { await expect(bindRunToolToExecution('tool-recover-async', 'wf-1')).resolves.toBe(true) - expect(fetchMock).toHaveBeenCalledTimes(4) - expect(fetchMock.mock.calls[3][0]).toBe('/api/copilot/confirm') - expect(fetchMock.mock.calls[3][1]?.body).toContain('"status":"background"') - expect(fetchMock.mock.calls[3][1]?.body).toContain('"executionId":"exec-recover-async"') + expect(fetchMock).toHaveBeenCalledTimes(7) + expect(fetchMock.mock.calls[6][0]).toBe('/api/copilot/confirm') + expect(fetchMock.mock.calls[6][1]?.body).toContain('"status":"background"') + expect(fetchMock.mock.calls[6][1]?.body).toContain('"executionId":"exec-recover-async"') expect( fetchMock.mock.calls.filter(([url]) => url === '/api/workflows/wf-1/execute') ).toHaveLength(1) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index f85769f99d9..015db935692 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -247,7 +247,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', 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( - 'run_function', + 'function_execute', expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) @@ -305,7 +305,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: names, }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ code, language, mountedSecrets: names }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry) } ) @@ -332,7 +332,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { requestedNames: ['CLI_TOKEN'], }) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }), { resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), @@ -353,7 +353,7 @@ describe('executeFunctionExecute trace-secret provenance', () => { ) expect(mockExecuteTool).toHaveBeenCalledWith( - 'run_function', + 'function_execute', 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( - 'run_function', + 'function_execute', expect.objectContaining({ sandboxId: 'sandbox-1' }), expect.objectContaining({ internalSandboxProfile: 'mothership' }) ) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 7580c3382da..8923e6a4ab8 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -745,7 +745,15 @@ export async function executeFunctionExecute( } try { - const result = await executeAppTool('run_function', enrichedParams, { + /** + * The copilot-facing tool is named `run_function`, but the app-tool + * registry id stays `function_execute` — the validator in tools/index.ts + * only admits `internalSandboxProfile` for that id, and every copilot + * call carries the internal `mothership` profile. Renaming this inner id + * without renaming the registry breaks every copilot sandbox call with + * "An internal sandbox profile may only be used with function_execute". + */ + const result = await executeAppTool('function_execute', enrichedParams, { resolvedSecretTraceRegistry: mountedRegistry, ...(context.abortSignal ? { signal: context.abortSignal } : {}), ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 76e001fe4f5..1c44356651e 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -44,6 +44,8 @@ export function createServerToolHandler(toolId: string): ToolHandler { return { success: true, output: result } } catch (error) { const caughtError = toError(error) + // The generic projection below records the swallowed cause on the active + // span itself (messageForCopilotApplicationError) so Tempo carries it. logger.error( 'Server tool execution failed', { diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index 9ea57ae9350..875346ea775 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -130,3 +130,36 @@ describe('WorkspaceVFS dynamic render reads', () => { }) }) }) + +describe('WorkspaceVFS lazy grep resilience', () => { + it('skips an unmaterializable lazy artifact instead of failing the whole sweep', async () => { + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + const internals = vfs as unknown as { + files: Map + registerLazy: (path: string, loader: () => Promise) => void + resolveLazyPath: (path: string) => Promise + } + internals.files.set('workflows/A/state.json', '{"needle": true}') + internals.registerLazy.call(vfs, 'knowledgebases/huge/documents.json', async () => { + throw new Error( + 'Knowledge base kb-1 has more than 10000 documents; documents.json cannot be materialized' + ) + }) + internals.registerLazy.call( + vfs, + 'knowledgebases/small/documents.json', + async () => '{"needle": "lazy"}' + ) + + const matches = (await vfs.grep('needle')) as Array<{ path: string }> + const paths = matches.map((m) => m.path) + expect(paths).toContain('workflows/A/state.json') + expect(paths).toContain('knowledgebases/small/documents.json') + + // Reading the failing artifact directly still surfaces its own error, and + // the loader stays re-armed for that read. + await expect( + internals.resolveLazyPath.call(vfs, 'knowledgebases/huge/documents.json') + ).rejects.toThrow('cannot be materialized') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 6a3716effba..451c535e018 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -747,7 +747,13 @@ export class WorkspaceVFS { if (!scope || ops.pathWithinGrepScope(path, scope)) targets.push(path) } if (targets.length === 0) return - await Promise.all(targets.map((path) => this.resolveLazyPath(path))) + // One unmaterializable artifact (e.g. an over-limit knowledge base's + // documents.json) must not fail the whole sweep — that would make every + // unscoped grep on the workspace error on content the caller never asked + // about. Skip it: grep proceeds over everything that resolved, the loader + // stays re-armed, and reading the failing path directly still surfaces its + // own error (resolveLazyPath logs each failure). + await Promise.allSettled(targets.map((path) => this.resolveLazyPath(path))) } /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 846442f7c1a..86e6ebcdda7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -81,10 +81,12 @@ "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "2.8.0", + "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", diff --git a/bun.lock b/bun.lock index a6a5f598185..0937d2dcf85 100644 --- a/bun.lock +++ b/bun.lock @@ -182,11 +182,13 @@ "@modelcontextprotocol/sdk": "1.29.0", "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/sdk-trace-base": "2.8.0", @@ -521,6 +523,7 @@ "name": "@sim/logger", "version": "0.1.0", "dependencies": { + "@opentelemetry/api-logs": "0.219.0", "@sim/utils": "workspace:*", "chalk": "5.6.2", }, diff --git a/packages/logger/package.json b/packages/logger/package.json index 0e8135a81cc..59e4ce2bdfb 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -25,6 +25,7 @@ "test:watch": "vitest" }, "dependencies": { + "@opentelemetry/api-logs": "0.219.0", "@sim/utils": "workspace:*", "chalk": "5.6.2" }, diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index f8d3057b9e2..a4e4d5bc79e 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -4,6 +4,7 @@ * Framework-agnostic logging utilities for the Sim platform. * Provides standardized console logging with environment-aware configuration. */ +import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { filterUndefined } from '@sim/utils/object' import chalk from 'chalk' import { getRequestContext } from './request-context' @@ -348,6 +349,8 @@ export class Logger { private log(level: LogLevel, message: string, ...args: unknown[]) { if (!this.shouldLog(level)) return + emitOtelLogRecord(level, this.module, message, this.metadata, args) + const timestamp = new Date().toISOString() const formattedArgs = this.formatArgs(args) @@ -478,3 +481,58 @@ export function createLogger(module: string, config?: LoggerConfig): Logger { export type { RequestContext } from './request-context' export { getRequestContext, runWithRequestContext } from './request-context' + +const OTEL_LOG_SEVERITY: Record = { + [LogLevel.DEBUG]: { number: SeverityNumber.DEBUG, text: 'DEBUG' }, + [LogLevel.INFO]: { number: SeverityNumber.INFO, text: 'INFO' }, + [LogLevel.WARN]: { number: SeverityNumber.WARN, text: 'WARN' }, + [LogLevel.ERROR]: { number: SeverityNumber.ERROR, text: 'ERROR' }, +} + +const OTEL_LOG_ARG_MAX_CHARS = 2000 + +/** + * Fans every accepted log line out through the OTel Logs API. Until an + * application installs a global LoggerProvider (apps/sim does in + * instrumentation-node.ts), the api-logs global is a no-op delegate, so this + * costs nothing in browsers, tests, and services that do not export logs. + * The active trace context is attached by the SDK, which is what enables + * span → logs correlation in the backend. Never allowed to throw into the + * console write path. + */ +function emitOtelLogRecord( + level: LogLevel, + module: string, + message: string, + metadata: Record, + args: unknown[] +): void { + try { + const severity = OTEL_LOG_SEVERITY[level] + const attributes: Record = { 'log.module': module } + for (const [key, value] of Object.entries(filterUndefined(metadata))) { + attributes[key] = String(value) + } + const firstError = args.find((arg) => arg instanceof Error) as Error | undefined + if (firstError) { + attributes['error.message'] = firstError.message + if (firstError.stack) attributes['error.stack'] = firstError.stack + } + const plainArgs = args.filter((arg) => !(arg instanceof Error)) + if (plainArgs.length > 0) { + try { + attributes['log.args'] = JSON.stringify(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS) + } catch { + attributes['log.args'] = String(plainArgs).slice(0, OTEL_LOG_ARG_MAX_CHARS) + } + } + logs.getLogger('sim').emit({ + severityNumber: severity.number, + severityText: severity.text, + body: message, + attributes, + }) + } catch { + // Log export must never break the primary console write path. + } +} From 752ddd258f88d7afaedc58a90050c81633ef71e7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 17:50:46 -0700 Subject: [PATCH 022/135] Backfill the subagent display name from the second start event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatch-time subagent_start fires before the trigger args (and therefore the name parameter) have streamed; the phase-3 start re-announces the lane with the name. The block builder was dropping that duplicate wholesale, losing the name on streaming providers — now it backfills subagentName onto the existing block instead. (The home turn-model path already reconciled this case.) --- .../sim/lib/copilot/request/go/stream.test.ts | 66 +++++++++++++++++++ apps/sim/lib/copilot/request/go/stream.ts | 27 +++++--- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 25f550a3a03..991570b1343 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -936,4 +936,70 @@ describe('copilot go stream helpers', () => { expect(subagentBlock?.parentSpanId).toBe('S1') expect(subagentBlock?.parentToolCallId).toBe('tc-deploy-inner') }) + + it('backfills the display name when only the second subagent start carries it', async () => { + const scope = { + lane: 'subagent' as const, + agentId: 'research', + parentToolCallId: 'tc-research', + spanId: 'S3', + parentSpanId: 'S1', + } + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([ + // Dispatch-time start: fires before the trigger args stream, so no name. + createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.span, + scope, + payload: { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-research' }, + }, + }), + // Phase-3 start re-announces the lane WITH the orchestrator-chosen name. + createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.span, + scope, + payload: { + kind: 'subagent', + event: 'start', + agent: 'research', + data: { tool_call_id: 'tc-research', name: 'Pricing research' }, + }, + }), + createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }), + ]) + ) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + + const subagentBlocks = context.contentBlocks.filter((block) => block.type === 'subagent') + expect(subagentBlocks).toHaveLength(1) + expect(subagentBlocks[0]?.subagentName).toBe('Pricing research') + }) }) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index e72bb29d0a0..b5dc0f2da3b 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -433,25 +433,36 @@ export async function runStreamLoop( context.subAgentToolCalls[toolCallId] ??= [] } if (toolCallId && subagentName) { + const payloadData = streamEvent.payload.data + const rawName = + payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) + ? (payloadData as Record).name + : undefined + const displayName = typeof rawName === 'string' && rawName ? rawName : undefined const openParents = (context.openSubagentParents ??= new Set()) if (!openParents.has(toolCallId)) { openParents.add(toolCallId) - const payloadData = streamEvent.payload.data - const displayName = - payloadData && typeof payloadData === 'object' && !Array.isArray(payloadData) - ? (payloadData as Record).name - : undefined context.contentBlocks.push({ type: 'subagent', content: subagentName, - ...(typeof displayName === 'string' && displayName - ? { subagentName: displayName } - : {}), + ...(displayName ? { subagentName: displayName } : {}), parentToolCallId: toolCallId, ...(spanId ? { spanId } : {}), ...(parentSpanId ? { parentSpanId } : {}), timestamp: Date.now(), }) + } else if (displayName) { + // The lane was opened by the dispatch-time start, which fires + // before the trigger args (and therefore the name) exist. The + // phase-3 start re-announces the lane WITH the name; backfill + // it instead of dropping the duplicate wholesale. + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if (b.type === 'subagent' && b.parentToolCallId === toolCallId) { + if (!b.subagentName) b.subagentName = displayName + break + } + } } } else { logger.warn('subagent start missing toolCallId or agent name', { From 0e78b14d2a8c26810d8d1fedf26ec9f79444cb53 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 13 Aug 2026 18:11:32 -0700 Subject: [PATCH 023/135] Support Slack bot connection flow --- .../special-tags/special-tags.test.tsx | 15 +++ .../components/special-tags/special-tags.tsx | 11 +- .../lib/copilot/generated/tool-catalog-v1.ts | 33 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 28 +++++ .../tool-executor/register-handlers.ts | 3 + .../copilot/tools/client/store-utils.test.ts | 32 +++++- .../lib/copilot/tools/client/store-utils.ts | 34 +++++- .../management/connect-slack-bot.test.ts | 105 ++++++++++++++++++ .../handlers/management/connect-slack-bot.ts | 91 +++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 2 + 10 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts create mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 6270761fc44..b2ba0064beb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1359,4 +1359,19 @@ describe('recoverTrailingBareOptions', () => { const { segments } = parseSpecialTags(`Pick one ${bareOptions}`, false) expect(segments.filter((segment) => segment.type === 'options')).toHaveLength(1) }) + + it('recovers an options payload wrapped in the singular `, + false + ) + const last = segments[segments.length - 1] + expect(last.type).toBe('options') + if (last.type === 'options') { + expect(Object.keys(last.data)).toEqual(['1', '2', '3']) + expect(last.data['1']?.title).toBe('Demo wait — sleep until an agent finishes') + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 21596a05ea1..ceb79140ce7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1459,11 +1459,20 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS * already parsed. Never applied mid-stream: a partial JSON tail must not * flicker between prose and a card. */ +const NEAR_MISS_OPTIONS_WRAPPER = /` fails + // the brace gate below). Unwrap it and let the strict shape check decide. + const nearMiss = NEAR_MISS_OPTIONS_WRAPPER.exec(text) + if (nearMiss) { + text = `${text.slice(0, nearMiss.index)}${nearMiss[1]}` + } if (!text.trimEnd().endsWith('}')) return // The payload nests objects, so the START brace is the first one from which // the remainder parses — probe brace positions left to right (bounded). diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index da266e91fd0..fe635a1bc9a 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -35,6 +35,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -163,6 +164,7 @@ export interface ToolCatalogEntry { | 'browser_type' | 'browser_wait_for' | 'call_integration_tool' + | 'connect_slack_bot' | 'cp' | 'create_empty_file' | 'create_workflow' @@ -1636,6 +1638,36 @@ export const CallIntegrationTool: ToolCatalogEntry = { requiresApproval: true, } +export const ConnectSlackBot: ToolCatalogEntry = { + id: 'connect_slack_bot', + name: 'connect_slack_bot', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { type: 'string', description: 'Optional description shown on the credential.' }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + requiredPermission: 'write', +} + export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -7103,6 +7135,7 @@ export const TOOL_CATALOG: Record = { [BrowserType.id]: BrowserType, [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, + [ConnectSlackBot.id]: ConnectSlackBot, [Cp.id]: Cp, [CreateEmptyFile.id]: CreateEmptyFile, [CreateWorkflow.id]: CreateWorkflow, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 105022d971a..d94d43860ee 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1580,6 +1580,34 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + connect_slack_bot: { + parameters: { + type: 'object', + properties: { + botTokenEnvVar: { + type: 'string', + description: + 'NAME of the environment variable holding the bot token (xoxb-..., OAuth & Permissions → Bot User OAuth Token). Pass the variable name, never the token value.', + }, + description: { + type: 'string', + description: 'Optional description shown on the credential.', + }, + displayName: { + type: 'string', + description: + 'Display name for the credential, shown in the credential picker (e.g. "Elder Bot"). Must be unique in the workspace.', + }, + signingSecretEnvVar: { + type: 'string', + description: + "NAME of the environment variable holding the Slack app's signing secret (Basic Information → App Credentials). Pass the variable name, never the secret value.", + }, + }, + required: ['displayName', 'signingSecretEnvVar', 'botTokenEnvVar'], + }, + resultSchema: undefined, + }, cp: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 61276eb7527..b78eac3859d 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { + ConnectSlackBot, Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, @@ -74,6 +75,7 @@ import { } from '../tools/handlers/deployment/manage' import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeListIntegrationTools } from '../tools/handlers/integration-tools' +import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot' import { executeManageCredential } from '../tools/handlers/management/manage-credential' import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' @@ -185,6 +187,7 @@ function buildHandlerMap(): Record { [ManageSandbox.id]: h(executeManageSandbox), [ManageSkill.id]: h(executeManageSkill), [ManageCredential.id]: h(executeManageCredential), + [ConnectSlackBot.id]: h(executeConnectSlackBot), [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), // Rolling-deploy compatibility for calls/checkpoints created before OAuth // moved into terminal credential cards. New agents no longer receive this diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 788b42781e6..81fa2870646 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -40,7 +40,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { @@ -49,6 +49,34 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('labels resource artifact reads distinctly instead of repeating the resource name', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/state.json', + })?.text + ).toBe('Read The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/Elder v2/The Elder/deployment.json', + })?.text + ).toBe('Read deployment status for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'workflows/Elder v2/The Elder/lint.json', + })?.text + ).toBe('Attempted to read lint results for The Elder') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'tables/CRM/Leads/views.json', + })?.text + ).toBe('Read views of Leads') + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'knowledgebases/Contracts/documents.json', + })?.text + ).toBe('Read documents in Contracts') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { @@ -60,7 +88,7 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'workflows/My%20Workflow/meta.json', })?.text - ).toBe('Read My Workflow') + ).toBe('Read metadata for My Workflow') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..2efa7d43fe5 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -107,11 +107,39 @@ function describeReadTarget(path: string | undefined): string | undefined { } if (resourceType === 'workflow') { - return stripExtension(getLeafResourceSegment(segments)) + return describeResourceArtifactTarget(segments) } - const resourceName = segments[1] || segments[segments.length - 1] - return stripExtension(resourceName) + return describeResourceArtifactTarget(segments) +} + +/** + * Resource-scoped artifact files, labeled the same prefix way as + * FILE_FACET_LABELS. `state.json` is the empty facet — reading a workflow means + * reading its state — so "Read The Elder", "Read metadata for The Elder", and + * "Read deployment status for The Elder" render as three distinct rows instead + * of three identical "Read The Elder" lines. + */ +const RESOURCE_ARTIFACT_LABELS: Record = { + 'state.json': '', + 'meta.json': 'metadata for', + 'lint.json': 'lint results for', + 'deployment.json': 'deployment status for', + 'versions.json': 'versions of', + 'executions.json': 'runs of', + 'views.json': 'views of', + 'documents.json': 'documents in', + 'connectors.json': 'connectors of', +} + +function describeResourceArtifactTarget(segments: string[]): string { + const lastSegment = segments[segments.length - 1] || '' + const resourceName = stripExtension(getLeafResourceSegment(segments)) + const artifactLabel = RESOURCE_ARTIFACT_LABELS[lastSegment] + if (artifactLabel !== undefined && segments.length > 1) { + return artifactLabel ? `${artifactLabel} ${resourceName}` : resourceName + } + return resourceName } // A workspace file is addressed as a directory of facets in the VFS diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts new file mode 100644 index 00000000000..46dc0b0dc04 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + performCreateCredential: vi.fn(), + getEffectiveDecryptedEnv: vi.fn(), +})) + +vi.mock('@/lib/credentials/orchestration', () => ({ + performCreateCredential: mocks.performCreateCredential, +})) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv, +})) + +import { executeConnectSlackBot } from './connect-slack-bot' + +const context = { userId: 'user-1', workspaceId: 'ws-1' } as never + +const validParams = { + displayName: 'Elder Bot', + signingSecretEnvVar: 'SLACK_SIGNING_SECRET', + botTokenEnvVar: 'SLACK_BOT_TOKEN', +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ + SLACK_SIGNING_SECRET: 'shhh', + SLACK_BOT_TOKEN: 'xoxb-123', + }) + mocks.performCreateCredential.mockResolvedValue({ + success: true, + created: true, + credential: { id: 'cred-1', displayName: 'Elder Bot' }, + }) +}) + +describe('executeConnectSlackBot', () => { + it('resolves env vars server-side and mints the credential with the request URL', async () => { + const result = await executeConnectSlackBot(validParams, context) + + expect(mocks.performCreateCredential).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-1', + userId: 'user-1', + type: 'service_account', + providerId: 'slack-custom-bot', + displayName: 'Elder Bot', + signingSecret: 'shhh', + botToken: 'xoxb-123', + }) + ) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + credentialId: 'cred-1', + created: true, + requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'), + }) + }) + + it('names the missing env vars without leaking any values', async () => { + mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('SLACK_BOT_TOKEN') + expect(result.error).not.toContain('shhh') + expect(mocks.performCreateCredential).not.toHaveBeenCalled() + }) + + it('requires displayName and both env var names', async () => { + const missingName = await executeConnectSlackBot( + { signingSecretEnvVar: 'A', botTokenEnvVar: 'B' }, + context + ) + expect(missingName.success).toBe(false) + expect(missingName.error).toContain('displayName') + + const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context) + expect(missingVars.success).toBe(false) + expect(missingVars.error).toContain('signingSecretEnvVar') + }) + + it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => { + mocks.performCreateCredential.mockResolvedValue({ + success: false, + error: 'Slack rejected the bot token', + }) + + const result = await executeConnectSlackBot(validParams, context) + + expect(result.success).toBe(false) + expect(result.error).toContain('Slack rejected the bot token') + }) + + it('requires workspace scope', async () => { + const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never) + expect(result.success).toBe(false) + expect(result.error).toContain('Workspace') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts new file mode 100644 index 00000000000..f02599e8d8e --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts @@ -0,0 +1,91 @@ +import { toError } from '@sim/utils/errors' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { performCreateCredential } from '@/lib/credentials/orchestration' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' + +/** + * Mints a reusable Slack custom-bot credential from secrets ALREADY stored as + * environment variables (a v1 setup being migrated, or values saved via + * set_environment_variables after a browser-agent extraction). The agent + * passes env-var NAMES; the values are resolved here and validated by the + * credential orchestration (Slack auth.test), so no secret ever appears in + * tool args, checkpoints, or transcripts. When the USER holds the secrets, + * the service_account credential card is the right path instead. + */ +export function executeConnectSlackBot( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as { + displayName?: string + description?: string + signingSecretEnvVar?: string + botTokenEnvVar?: string + } + return (async () => { + try { + if (!context?.userId) { + return { success: false, error: 'Authentication required' } + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, error: 'Workspace scope required' } + } + const { displayName, description, signingSecretEnvVar, botTokenEnvVar } = params + if (!displayName) { + return { success: false, error: 'displayName is required' } + } + if (!signingSecretEnvVar || !botTokenEnvVar) { + return { + success: false, + error: + 'signingSecretEnvVar and botTokenEnvVar are required: the NAMES of the environment variables holding the Slack signing secret and bot token. Save the values with set_environment_variables first if needed.', + } + } + + const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) + const missing = [signingSecretEnvVar, botTokenEnvVar].filter((name) => !env[name]) + if (missing.length > 0) { + return { + success: false, + error: `Environment variable(s) not found: ${missing.join(', ')}. Check environment/ in the VFS, or save the values with set_environment_variables first.`, + } + } + + const result = await performCreateCredential({ + workspaceId, + userId: context.userId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + displayName, + description, + signingSecret: env[signingSecretEnvVar], + botToken: env[botTokenEnvVar], + }) + if (!result.success || !result.credential) { + return { + success: false, + error: + result.error || + 'Failed to connect the Slack custom bot. If a credential with this display name already exists, reuse it (environment/credentials.json) or pick a different name.', + } + } + return { + success: true, + output: { + credentialId: result.credential.id, + displayName: result.credential.displayName, + created: result.created !== false, + // The Slack app's Event Subscriptions Request URL — one per + // credential, shared by every trigger that selects it; live + // immediately, no deployment needed. + requestUrl: buildSlackCustomBotRequestUrl(result.credential.id), + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } + })() +} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8f1fcc83437..4059797c33e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -492,6 +492,7 @@ const TOOL_TITLES: Record = { list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', save_upload: 'Saving upload', + connect_slack_bot: 'Connecting Slack bot', manage_sandbox: 'Managing sandbox', manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', @@ -1034,6 +1035,7 @@ const COMPLETED_VERB_REWRITES: Record = { Crawling: 'Crawled', Creating: 'Created', Deleting: 'Deleted', + Connecting: 'Connected', Deploying: 'Deployed', Dragging: 'Dragged', Inserting: 'Inserted', From a4cdfa0e922ee1346af6ecec277a01831edee87b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 10:04:33 -0700 Subject: [PATCH 024/135] Harden Copilot error and VFS handling --- apps/sim/lib/copilot/application/error.ts | 23 ++++++++++++++++++++++- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 21 +++++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/copilot/application/error.ts index 1626fb942b2..4f5c8626162 100644 --- a/apps/sim/lib/copilot/application/error.ts +++ b/apps/sim/lib/copilot/application/error.ts @@ -20,6 +20,27 @@ export function messageForCopilotApplicationError( if (classified && classified.code !== 'internal') { return classified.message } - trace.getActiveSpan()?.recordException(toError(error)) + trace.getActiveSpan()?.recordException(flattenErrorChain(error)) return fallback } + +/** + * Wrapper errors (Drizzle's "Failed query: ") bury the actionable cause — + * the Postgres constraint/violation — in `cause`. Join the chain so the span + * exception carries the part an investigator actually needs. + */ +function flattenErrorChain(error: unknown): Error { + const primary = toError(error) + const parts = [primary.message] + let cursor: unknown = primary.cause + let depth = 0 + while (cursor && depth < 4) { + parts.push(toError(cursor).message) + cursor = toError(cursor).cause + depth += 1 + } + if (parts.length === 1) return primary + const flattened = new Error(parts.join(' ← ')) + flattened.stack = primary.stack + return flattened +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 451c535e018..83a425199bb 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1806,11 +1806,24 @@ export class WorkspaceVFS { }) } + // deployment.json exists for EVERY workflow: "is it deployed?" is a + // question with an answer either way, and a not-found error here was a + // recurring red herring — agents probing an undeployed workflow read a + // failure instead of the fact. Versions stay gated: they genuinely + // don't exist before the first deploy. + this.registerLazy(`${prefix}deployment.json`, async () => { + if (!versionedWorkflowIds.has(wf.id)) { + return JSON.stringify({ + deployed: false, + note: 'This workflow has never been deployed.', + }) + } + const deploymentData = await this.loadDeployments(wf.id) + return deploymentData + ? serializeDeployments(deploymentData) + : JSON.stringify({ deployed: false, note: 'This workflow has never been deployed.' }) + }) if (versionedWorkflowIds.has(wf.id)) { - this.registerLazy(`${prefix}deployment.json`, async () => { - const deploymentData = await this.loadDeployments(wf.id) - return deploymentData ? serializeDeployments(deploymentData) : null - }) this.registerLazy(`${prefix}versions.json`, async () => { const deploymentData = await this.loadDeployments(wf.id) return deploymentData?.versions && deploymentData.versions.length > 0 From e0a14840c002c2352a0ee4296ee1ac5804f693ae Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 10:57:29 -0700 Subject: [PATCH 025/135] Harden VFS resource operations --- apps/sim/app/api/table/utils.ts | 23 ++++++---- apps/sim/lib/copilot/tools/handlers/vfs.ts | 19 ++++++++- apps/sim/lib/copilot/vfs/operations.test.ts | 23 +++++++++- apps/sim/lib/copilot/vfs/operations.ts | 37 +++++++++++++--- .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 17 ++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 42 +++++++++++++++++-- apps/sim/lib/core/config/feature-flags.ts | 3 +- 7 files changed, 143 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index e049b1a3f68..dee6704c830 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -27,14 +27,13 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceOrganizationId } from '@/lib/workspaces/utils' /** - * Gate for the v2 tables HTTP API (`tables-v2-api` flag). Returns a 404 response - * when the flag is off for the caller — the surface behaves as if it doesn't - * exist — or `null` to proceed. Gated by userId + the workspace's org cohort. - * - * **Call this AFTER the authz check, never before.** Ahead of authz it does a - * primary-DB read keyed on a caller-supplied `workspaceId`, and the 404-vs-403 - * split tells an unauthorized caller whether that workspace's org is in the - * rollout cohort. + * Gate for the internal predicate-grammar table query route (`tables-v2-api` + * flag). Runs AFTER authorization, so the caller has already proven read + * access to the table — hiding the gate behind a bare 404 at that point + * serves nobody and reads as data loss (live incident: the table_v2 block + * hard-"Not found"-ing on every query while the copilot gateway, which + * bypasses HTTP, found the rows). Authorized callers get an honest 403 + * naming the gate instead. */ export async function tablesV2GateError( userId: string, @@ -42,7 +41,13 @@ export async function tablesV2GateError( ): Promise { const orgId = await getWorkspaceOrganizationId(workspaceId) if (await isFeatureEnabled('tables-v2-api', { userId, orgId })) return null - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + return NextResponse.json( + { + error: 'The v2 table query API is not enabled for this workspace', + code: 'tables_v2_disabled', + }, + { status: 403 } + ) } /** diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index dfa61ea3881..c8f6d2c642d 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -421,7 +421,7 @@ export async function executeVfsRead( success: false, error: isOversizedReadPlaceholder(fileContent) ? fileContent.content - : 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + : `Read result too large to return inline. Locate the relevant section first — grep({pattern: \"...\", path: \"${path}\"}) — then page it with read({path: \"${path}\", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, } } const windowedFileContent = applyWindow(fileContent) @@ -458,7 +458,22 @@ export async function executeVfsRead( } } - const result = await vfs.read(path, offset, limit) + let resolvedReadPath = path + let result = await vfs.read(path, offset, limit) + if (!result) { + // Same name, wrong encoding (spaces instead of %20) is the most common + // path mistake and carries zero ambiguity — resolve it instead of + // bouncing the model through a not-found round-trip. + const decodedEquivalent = vfs.resolveDecodedEquivalent(path) + if (decodedEquivalent) { + logger.info('vfs_read resolved decoded-equivalent path', { + requested: path, + resolved: decodedEquivalent, + }) + resolvedReadPath = decodedEquivalent + result = await vfs.read(decodedEquivalent, offset, limit) + } + } if (!result) { const suggestions = vfs.suggestSimilar(path) logger.warn('vfs_read file not found', { path, suggestions }) diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index 26c5b89b84c..b1d308f7250 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -2,7 +2,13 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { glob, grep, grepReadResult, WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { + glob, + grep, + grepReadResult, + pathWithinGrepScope, + WorkspaceFileGrepError, +} from '@/lib/copilot/vfs/operations' import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' function vfsFromEntries(entries: [string, string][]): Map { @@ -235,3 +241,18 @@ describe('grepReadResult placeholders', () => { expect(grepResult({ content, totalLines: 1 })).toHaveLength(1) }) }) + +describe('decode-normalized matching', () => { + it('glob matches a decoded display pattern against encoded keys, returning canonical paths', () => { + const files = new Map([['workflows/Elder%20v2/The%20Elder/state.json', '{}']]) + const matches = glob(files, 'workflows/Elder v2/**') + expect(matches).toContain('workflows/Elder%20v2/The%20Elder/state.json') + }) + + it('grep scope written in decoded form filters in encoded keys', () => { + expect( + pathWithinGrepScope('workflows/Elder%20v2/The%20Elder/state.json', 'workflows/Elder v2') + ).toBe(true) + expect(pathWithinGrepScope('workflows/Other/state.json', 'workflows/Elder v2')).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index ac6d87c78a6..325609f63f4 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import micromatch from 'micromatch' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' import { isNonGreppablePlaceholder, type PlaceholderKind, @@ -111,6 +112,20 @@ export interface ReadResult { * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. */ +/** + * Matching is decode-normalized: canonical keys are percent-encoded, but the + * model routinely writes the decoded display form ("Elder v2"). Comparing both + * sides decoded makes scope/glob matching tolerant of the encoding difference + * while canonical (encoded) inputs behave exactly as before. Returned paths + * are always the canonical encoded keys. + */ +function decodePathForMatch(path: string): string { + return path + .split('/') + .map((segment) => decodeVfsSegmentSafe(segment)) + .join('/') +} + const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, @@ -139,14 +154,19 @@ function splitLinesForGrep(content: string): string[] { */ export function pathWithinGrepScope(filePath: string, scope: string): boolean { const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope) + const decodedPath = decodePathForMatch(filePath) + const decodedScope = decodePathForMatch(scope) if (scopeUsesStarOrQuestionGlob) { - return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) + return ( + micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodedPath, decodedScope, VFS_GLOB_OPTIONS) + ) } - const base = scope.replace(/\/+$/, '') + const base = decodedScope.replace(/\/+$/, '') if (base === '') { return true } - return filePath === base || filePath.startsWith(`${base}/`) + return decodedPath === base || decodedPath.startsWith(`${base}/`) } /** @@ -275,15 +295,22 @@ export function glob(files: Map, pattern: string): string[] { } } + const decodedPattern = decodePathForMatch(pattern) for (const filePath of files.keys()) { if (filePath.endsWith('/.folder')) continue - if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { + if ( + micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodePathForMatch(filePath), decodedPattern, VFS_GLOB_OPTIONS) + ) { result.add(filePath) } } for (const dir of directories) { - if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) { + if ( + micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS) || + micromatch.isMatch(decodePathForMatch(dir), decodedPattern, VFS_GLOB_OPTIONS) + ) { result.add(dir) } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index 875346ea775..d267b508656 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -163,3 +163,20 @@ describe('WorkspaceVFS lazy grep resilience', () => { ).rejects.toThrow('cannot be materialized') }) }) + +describe('WorkspaceVFS decoded-equivalent resolution', () => { + it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => { + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + const internals = vfs as unknown as { files: Map } + internals.files.set('workflows/Elder%20v2/The%20Elder/state.json', '{}') + + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBe( + 'workflows/Elder%20v2/The%20Elder/state.json' + ) + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/meta.json')).toBeNull() + + // Two keys decoding identically (pathological) must refuse to guess. + internals.files.set('workflows/Elder v2/The Elder/state.json', '{}') + expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBeNull() + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 83a425199bb..fc13422e547 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -60,6 +60,7 @@ import { buildVfsFolderPathMap, canonicalWorkflowVfsDir, canonicalWorkspaceFilePath, + decodeVfsSegmentSafe, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' @@ -1032,9 +1033,18 @@ export class WorkspaceVFS { const normalized = path.replace(/^\/+/, '') // Prefer the path verbatim when it is itself a file leaf (e.g. a file literally // named "content"); otherwise drop a trailing "/content" read suffix. - const leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') - - const isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + let leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') + + let isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + if (isWorkspaceFilePath && !this.files.has(leaf)) { + // Same encoding tolerance as vfs_read: a decoded display form that maps + // to exactly one canonical key resolves instead of erroring. + const decodedEquivalent = this.resolveDecodedEquivalent(leaf) + if (decodedEquivalent) { + leaf = decodedEquivalent + isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + } + } if (!isWorkspaceFilePath || !this.files.has(leaf)) { const suggestions = this.suggestSimilar(leaf) const hint = @@ -1078,6 +1088,25 @@ export class WorkspaceVFS { return ops.suggestSimilar(this.keyView(true), missingPath, max) } + /** + * Resolves a missing path to an existing one when the two differ ONLY by + * percent-encoding (the model typed the decoded display form — spaces + * instead of %20). Returns the canonical existing path when exactly one key + * decodes to the same segments; ambiguity or a genuine miss returns null so + * the not-found error (with suggestions) still fires. Never fuzzy: same + * name, different bytes only. + */ + resolveDecodedEquivalent(missingPath: string): string | null { + const target = decodeVfsPathSegmentsSafe(missingPath) + let match: string | null = null + for (const key of this.keyView(true).keys()) { + if (decodeVfsPathSegmentsSafe(key) !== target) continue + if (match !== null) return null + match = key + } + return match + } + private async resolveWorkspaceFileForDynamicRead( path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' @@ -2688,3 +2717,10 @@ export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' export function sanitizeName(name: string): string { return normalizeVfsSegment(name) } + +function decodeVfsPathSegmentsSafe(path: string): string { + return path + .split('/') + .map((segment) => decodeVfsSegmentSafe(segment)) + .join('/') +} diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index cef6ab2e66b..bf364037c49 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -118,7 +118,8 @@ const FEATURE_FLAGS = { 'tables-v2-api': { description: 'Gate the internal predicate-grammar table query route (POST /api/table/[tableId]/query), ' + - 'its only caller. When off, that route returns 404 as if it does not exist. Despite the ' + + 'its only caller. When off, that route returns 403 naming the gate (post-authz, so the ' + + 'masquerade 404 served nobody and broke the table_v2 block confusingly). Despite the ' + 'name it does NOT gate any /api/v2/tables route — the public v2 tables surface is gated ' + 'by v2-api alone. Gated by userId/orgId/admins via AppConfig; off-AppConfig falls back to ' + 'TABLES_V2_API.', From 63bc10816a3e27b2873d40e2c2f3a46a5b377218 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:05:45 -0700 Subject: [PATCH 026/135] Show 'Waiting for the first of N agents' for mode-any waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wait_agents title ignored the mode argument, so an any-mode wait over three agents read 'Waiting for 3 agents' while the model narrated waiting for the first — contradicting the transcript. --- apps/sim/lib/copilot/tools/tool-display.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 4059797c33e..ad08842187d 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -610,12 +610,17 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } -/** Title for a wait_agents sleep, naming the agent(s) being collected. */ +/** Title for a wait_agents sleep, naming the agent(s) being collected and honoring mode "any". */ function waitAgentsTitle(args: ToolArgs): string { const raw = args?.agent_ids const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + const anyMode = stringArg(args, 'mode') === 'any' if (ids.length === 1) return `Waiting for ${ids[0]}` - if (ids.length > 1) return `Waiting for ${ids.length} agents` + if (ids.length > 1) { + return anyMode + ? `Waiting for the first of ${ids.length} agents` + : `Waiting for ${ids.length} agents` + } return 'Waiting for agents' } From c51683658e12757968f8647af64a9bfbe67e685e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:25:40 -0700 Subject: [PATCH 027/135] Collapsed-by-default agent cards with live intent status lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagents now narrate their work through 3-5 words tags (a fleet-wide prompt protocol on the mothership side). The turn model streams each subagent's text through a split-safe tag parser: complete tags update the agent's currentIntent and disappear from the prose, tags split across deltas are carried until their close arrives, and a tag that never closes flushes back as plain text. The agent card renders as one line — display name (or agent label) plus the latest intent, replaced inline as the agent shifts gears — and never auto-expands; expanding to the full tool log is a deliberate click. Only an outstanding permission prompt or a browser hand-back forces a group open. Intents persist on the subagent block (and through the legacy persisted- message paths) so reloads keep the last status, and a renamed reinvocation now takes the latest name instead of pinning the first. --- .../agent-group/agent-group.test.ts | 7 ++ .../components/agent-group/agent-group.tsx | 32 +++++---- .../message-content/message-content.tsx | 5 ++ .../home/hooks/stream/turn-model-serialize.ts | 2 + .../home/hooks/stream/turn-model.test.ts | 40 +++++++++++ .../home/hooks/stream/turn-model.ts | 70 ++++++++++++++++++- .../app/workspace/[workspaceId]/home/types.ts | 2 + apps/sim/lib/copilot/chat/display-message.ts | 1 + .../sim/lib/copilot/chat/persisted-message.ts | 7 ++ apps/sim/lib/copilot/request/types.ts | 2 + 10 files changed, 151 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index 4808893b4eb..c2392880b27 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -213,6 +213,13 @@ describe('AgentGroup browser takeover', () => { }) expect(container.querySelector('.animate-stream-fade-in')).toBeNull() + // Groups never auto-expand: the answered question lives inside the + // collapsed log until the user opens it manually. + const headerToggle = container.querySelector('button[class*="group/agent"]') + expect(headerToggle).not.toBeNull() + act(() => { + headerToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) const resumedLog = container.querySelector('[data-state="open"]') const answeredQuestion = container.querySelector('[data-takeover-answer="true"]') expect(answeredQuestion?.textContent).toContain(reason) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 103e6ba6e4f..77a7eb18032 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -21,6 +21,8 @@ export interface NestedAgentGroup { id: string agentName: string agentLabel: string + /** The agent's latest tag — the collapsed row's live status. */ + intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -34,6 +36,8 @@ export type AgentGroupItem = interface AgentGroupProps { agentName: string agentLabel: string + /** The agent's latest tag — shown inline after the label. */ + intent?: string items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean @@ -107,6 +111,7 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { export function AgentGroup({ agentName, agentLabel, + intent, items, isDelegating = false, isStreaming = false, @@ -114,6 +119,7 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) + const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -123,17 +129,12 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Expand while the turn is live and any of: the lane is open (the subagent is - // actively running), this is the current/latest section, or there is unresolved - // work. A finished group stays open until the NEXT section starts (it is no - // longer the latest), instead of collapsing the instant its own work resolves. - // Keying "still running" off the lane-open signal (not `resolved` alone) avoids - // a collapse/reopen flicker on parallel siblings: a subagent's tools all - // momentarily read "done" in the gap between its last search and its `respond` - // ("Gathering thoughts") tool, transiently flipping `resolved` true; the open - // lane bridges that gap so the row never collapses mid-run. The turn ending - // (isStreaming false) collapses everything; a manual toggle pins the choice. - const autoExpanded = isStreaming && (isCurrentSection || isLaneOpen || !resolved) + // Agent groups never auto-expand: the collapsed row IS the live view — the + // label plus the agent's latest tag, replaced inline as it works. + // Expanding is a deliberate user action (the toggle below); only an + // outstanding permission prompt or a browser hand-back forces the group + // open, because the turn cannot proceed while they wait off-screen. + const autoExpanded = false const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn @@ -166,9 +167,9 @@ export function AgentGroup({ {isWorking ? ( - {agentLabel} + {headerText} ) : ( - {agentLabel} + {headerText} )} {isWorking ? ( - {agentLabel} + {headerText} ) : ( - {agentLabel} + {headerText} )} )} @@ -216,6 +217,7 @@ export function AgentGroup({ tag (parsed upstream from its text). */ + intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -381,6 +383,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName + if (block.subagentIntent) g.intent = block.subagentIntent if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -625,6 +628,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) if (block.subagentName) g.agentLabel = block.subagentName + if (block.subagentIntent) g.intent = block.subagentIntent if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) @@ -956,6 +960,7 @@ function MessageContentInner({ key={segment.id} agentName={segment.agentName} agentLabel={segment.agentLabel} + intent={segment.intent} items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 6088ee0419a..4a61a120c4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -168,6 +168,7 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { type: 'subagent', content: node.agentId, ...(node.displayName ? { subagentName: node.displayName } : {}), + ...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -271,6 +272,7 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { data: { ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), }, }, scopeFor(block), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 85c0b420e73..0af21ac7e24 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -260,6 +260,46 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').displayName).toBe('Pricing research') }) + it('parses intent tags out of subagent text into the agent status', () => { + const textEv = (seq: number, text: string) => + envelope( + seq, + 'text', + { channel: 'assistant', text }, + { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } + ) + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-f'), + textEv(2, 'Drafting chapter outline\nStarting on the outline now.'), + ]) + expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline') + const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') + expect(text && text.kind === 'text' ? text.text : '').not.toContain('') + expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline') + }) + + it('handles an intent tag split across deltas and takes the latest tag', () => { + const textEv = (seq: number, text: string) => + envelope( + seq, + 'text', + { channel: 'assistant', text }, + { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } + ) + const m = apply([ + spanStart(1, 'S1', 'file', 'tc-f'), + textEv(2, 'ok. Writing first chap'), + textEv(4, 'tertext after. Reviewing draft'), + ]) + expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft') + const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') + const rendered = text && text.kind === 'text' ? text.text : '' + expect(rendered).toContain('ok. ') + expect(rendered).toContain('text after. ') + expect(rendered).not.toContain('intent>') + }) + it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 490f86d0109..820b41884ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -87,6 +87,10 @@ export interface AgentNode extends NodeBase { triggerToolCallId?: string /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ displayName?: string + /** The agent's latest tag — the collapsed card's live status line. */ + currentIntent?: string + /** Streaming carry for an intent tag split across text deltas (never serialized). */ + intentCarry?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -297,6 +301,57 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void { closeOpenText(model, spanId, 'thinking', atMs) } +const INTENT_OPEN = '' +const INTENT_CLOSE = '' +/** A tag that never closes within this many chars flushes back as plain text. */ +const INTENT_CARRY_MAX = 240 + +/** Length of the longest buf suffix that could still grow into `token`. */ +function partialSuffixLen(buf: string, token: string): number { + const max = Math.min(buf.length, token.length - 1) + for (let len = max; len > 0; len--) { + if (token.startsWith(buf.slice(buf.length - len))) return len + } + return 0 +} + +/** + * Streams a subagent's assistant text through the protocol: complete + * tags update the owning agent's currentIntent and are removed from the prose; + * a tag split across deltas is carried until its close arrives. The returned + * string is what the transcript should render. + */ +function filterIntentText(owner: AgentNode, incoming: string): string { + let buf = (owner.intentCarry ?? '') + incoming + owner.intentCarry = '' + let out = '' + while (buf) { + const openIdx = buf.indexOf(INTENT_OPEN) + if (openIdx === -1) { + const keep = partialSuffixLen(buf, INTENT_OPEN) + out += keep ? buf.slice(0, buf.length - keep) : buf + if (keep) owner.intentCarry = buf.slice(buf.length - keep) + break + } + out += buf.slice(0, openIdx) + const rest = buf.slice(openIdx) + const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) + if (closeIdx === -1) { + if (rest.length > INTENT_CARRY_MAX) { + out += rest + } else { + owner.intentCarry = rest + } + break + } + const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim() + if (intent) owner.currentIntent = intent + buf = rest.slice(closeIdx + INTENT_CLOSE.length) + if (buf.startsWith('\n')) buf = buf.slice(1) + } + return out +} + function appendText( model: TurnModel, spanId: string, @@ -462,7 +517,15 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.text: { const payload = envelope.payload ensureSubagentLane(model, spanId, scope, seq, tsMs) - appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) + let text = payload.text + if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') { + const ownerId = model.agentBySpanId.get(spanId) + const owner = ownerId ? model.nodes.get(ownerId) : undefined + if (owner && owner.kind === 'agent') { + text = filterIntentText(owner, text) + } + } + appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -566,6 +629,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const displayName = asString(data?.name) + const restoredIntent = asString(data?.intent) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -584,7 +648,8 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // scope.agentId can name the forwarding caller (e.g. superagent), // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId - if (displayName && !existing.displayName) existing.displayName = displayName + if (displayName) existing.displayName = displayName + if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -607,6 +672,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), ...(displayName ? { displayName } : {}), + ...(restoredIntent ? { currentIntent: restoredIntent } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index eedb402ba87..2f0124bacfc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -116,6 +116,8 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string + /** The agent's latest tag at serialization time — the collapsed card's status line. */ + subagentIntent?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 28a348a5837..91ce4906570 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -96,6 +96,7 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi type: ContentBlockType.subagent, content: block.content, ...(block.name ? { subagentName: block.name } : {}), + ...(block.intent ? { subagentIntent: block.intent } : {}), } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index c57e49b5a85..9e8c9ba26e7 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -55,6 +55,8 @@ export interface PersistedContentBlock { content?: string /** Orchestrator-chosen display name on a subagent start block. */ name?: string + /** The agent's latest tag at persistence time. */ + intent?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -248,6 +250,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } case 'subagent_text': return { @@ -442,6 +445,8 @@ interface RawBlock { /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ name?: string subagentName?: string + intent?: string + subagentIntent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -510,6 +515,7 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { } if (block.agent) result.agent = block.agent if (block.name) result.name = block.name + if (block.intent) result.intent = block.intent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -592,6 +598,7 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), + ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 35127f3edf9..eaaf50d95c8 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -82,6 +82,8 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block. */ subagentName?: string + /** The agent's latest tag. */ + subagentIntent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run From 97cd8b776154a16de092c567cd69a0e9188cb2b2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:36:58 -0700 Subject: [PATCH 028/135] Add the internal in-band tool execution route for live mothership turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/copilot/tools/execute (INTERNAL_API_SECRET, Go→Sim) runs one sim-server tool through the same server tool router the resume driver uses and returns the result synchronously — no checkpoint. This is what lets background (async) subagents write files/tables/knowledge, and lets the main lane keep streaming (instead of checkpoint-pausing and killing every background run) while async agents are live. --- .../app/api/copilot/tools/execute/route.ts | 92 +++++++++++++++++++ apps/sim/lib/api/contracts/copilot.ts | 14 +++ .../lib/copilot/generated/trace-spans-v1.ts | 4 + scripts/check-api-validation-contracts.ts | 4 +- 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/copilot/tools/execute/route.ts diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts new file mode 100644 index 00000000000..4aadb90aee9 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' +import { validationErrorResponse } from '@/lib/api/server' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +import { checkInternalApiKey } from '@/lib/copilot/request/http' +import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CopilotToolExecuteInternalAPI') + +// POST /api/copilot/tools/execute — internal (Go → Sim) in-band execution of +// one sim-server tool announced on a LIVE mothership turn. This is what lets +// background (async) subagents — and the main lane while background agents are +// running — use sim-executed tools without a checkpoint pause: Go calls here +// synchronously instead of parking the turn, and the tool runs through the +// same server tool router the resume driver uses. Trusted server-to-server +// only: Go supplies the acting user, proven by the internal API secret. +export const POST = withRouteHandler((request: NextRequest) => + withIncomingGoSpan( + request.headers, + TraceSpan.CopilotToolsExecuteInband, + undefined, + async (rootSpan) => { + const authResult = checkInternalApiKey(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication failed' }, + { status: 401 } + ) + } + + // boundary-raw-json: tolerant parse; validation happens via the contract schema below + const body = await request.json().catch(() => ({})) + const validation = copilotToolExecuteInternalBodySchema.safeParse(body) + if (!validation.success) { + return validationErrorResponse(validation.error, 'Invalid request body') + } + const { + toolCallId, + toolName, + params, + userId, + workflowId, + workspaceId, + chatId, + messageId, + parentToolCallId, + userPermission, + } = validation.data + rootSpan.setAttributes({ + [TraceAttr.ToolName]: toolName, + [TraceAttr.ToolCallId]: toolCallId, + [TraceAttr.UserId]: userId, + }) + + try { + const handler = createServerToolHandler(toolName) + const result = await handler(params, { + userId, + workflowId: workflowId ?? '', + workspaceId, + chatId, + messageId, + toolCallId, + parentToolCallId, + userPermission, + copilotToolExecution: true, + }) + if (!result.success) { + logger.warn('In-band tool execution failed', { + toolName, + toolCallId, + error: result.error, + }) + } + return NextResponse.json({ + success: result.success, + ...(result.output !== undefined ? { output: result.output } : {}), + ...(result.error ? { error: result.error } : {}), + }) + } catch (err) { + const message = getErrorMessage(err) + logger.error('In-band tool execution threw', { toolName, toolCallId, error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } + } + ) +) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 2f9a0bd4ca7..16589b6a294 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -148,6 +148,20 @@ export const copilotChatSteerBodySchema = z.object({ }) export type CopilotChatSteerBody = z.input +export const copilotToolExecuteInternalBodySchema = z.object({ + toolCallId: z.string().min(1, 'toolCallId is required'), + toolName: z.string().min(1, 'toolName is required'), + params: z.record(z.string(), z.unknown()).default({}), + userId: z.string().min(1, 'userId is required'), + workflowId: z.string().optional(), + workspaceId: z.string().optional(), + chatId: z.string().optional(), + messageId: z.string().optional(), + parentToolCallId: z.string().optional(), + userPermission: z.string().optional(), +}) +export type CopilotToolExecuteInternalBody = z.input + export const copilotChatGetQuerySchema = z .object({ workflowId: z.string().optional(), diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/copilot/generated/trace-spans-v1.ts index ac8665f8d22..1dc7cc59300 100644 --- a/apps/sim/lib/copilot/generated/trace-spans-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-spans-v1.ts @@ -71,6 +71,7 @@ export const TraceSpan = { CopilotToolWaitForClientResult: 'copilot.tool.wait_for_client_result', CopilotToolWaitForPermission: 'copilot.tool.wait_for_permission', CopilotToolPermissionDecide: 'copilot.tool_permission.decide', + CopilotToolsExecuteInband: 'copilot.tools.execute_inband', CopilotToolsHandleResourceSideEffects: 'copilot.tools.handle_resource_side_effects', CopilotToolsWriteCsvToTable: 'copilot.tools.write_csv_to_table', CopilotToolsWriteOutputFile: 'copilot.tools.write_output_file', @@ -81,6 +82,7 @@ export const TraceSpan = { GenAiAgentExecute: 'gen_ai.agent.execute', LlmStream: 'llm.stream', ProviderRouterRoute: 'provider.router.route', + SimExecuteTool: 'sim.execute_tool', SimUpdateCost: 'sim.update_cost', SimValidateApiKey: 'sim.validate_api_key', ToolAsyncWaiterWait: 'tool.async_waiter.wait', @@ -154,6 +156,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'copilot.tool.wait_for_client_result', 'copilot.tool.wait_for_permission', 'copilot.tool_permission.decide', + 'copilot.tools.execute_inband', 'copilot.tools.handle_resource_side_effects', 'copilot.tools.write_csv_to_table', 'copilot.tools.write_output_file', @@ -164,6 +167,7 @@ export const TraceSpanValues: readonly TraceSpanValue[] = [ 'gen_ai.agent.execute', 'llm.stream', 'provider.router.route', + 'sim.execute_tool', 'sim.update_cost', 'sim.validate_api_key', 'tool.async_waiter.wait', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ba484e5faf6..5a40d039ee0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1106, - zodRoutes: 1106, + totalRoutes: 1107, + zodRoutes: 1107, nonZodRoutes: 0, } as const From dfccbac353f0cdcb9e1a98e974ab6e5884eaa6b9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 11:52:21 -0700 Subject: [PATCH 029/135] Persist resource side effects for in-band tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files/tables created through the internal execute route now register on the chat's resources exactly like the resume driver's executions — the route runs the same handleResourceSideEffects pass (persistence only; an out-of-band route has no live event sink, so mid-turn chip pushes are a follow-up). --- .../app/api/copilot/tools/execute/route.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index 4aadb90aee9..82c43929845 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -7,6 +7,8 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' +import type { ToolCallResult } from '@/lib/copilot/request/types' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -77,6 +79,28 @@ export const POST = withRouteHandler((request: NextRequest) => error: result.error, }) } + if (result.success && chatId) { + // Persist created/deleted resources on the chat (file chips, table + // links) exactly like the resume driver does. No live event sink + // exists for an out-of-band route, so chips surface from the + // persisted chat resources rather than a mid-turn push. + const asToolResult = { success: result.success, output: result.output } as ToolCallResult + await handleResourceSideEffects( + toolName, + params, + asToolResult, + asToolResult, + chatId, + undefined, + () => false + ).catch((err) => { + logger.warn('In-band resource side effects failed', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) + }) + } return NextResponse.json({ success: result.success, ...(result.output !== undefined ? { output: result.output } : {}), From 90d07b0e3bad6dbc6a67cc7855adcf19a9a101e4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:31:40 -0700 Subject: [PATCH 030/135] Extract intents from group text on every path, sync and async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn-model intent filter only fires for span-scoped subagent lanes, but this surface also delivers subagent text through the legacy block path — so tags flowed through unparsed and rendered as prose rows. Groups now extract intents from their accumulated text at append time: the last complete tag becomes the card's status line and every complete tag is stripped from the rendered prose. Covers span-scoped, legacy, and persisted-reload paths for both synchronous and background delegations. --- .../message-content/message-content.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 910e8a08857..076f5e1f61b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -224,12 +224,37 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { * Streamed chunks and resume legs are concatenated verbatim, so a token split * like `v2.` + `1` is never mutated. */ +const INTENT_TAG_RE = /([\s\S]*?)<\/intent>\n?/g + +/** + * Extracts complete tags from a group's accumulated text: the last + * tag becomes the group's live status line and every complete tag is removed + * from the rendered prose. Runs on the accumulated buffer each append, so a + * tag split across streamed chunks is picked up once its close arrives — + * covering the span path, the legacy block path, and persisted reloads alike. + */ +function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void { + let lastIntent: string | undefined + const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => { + const intent = inner.trim() + if (intent) lastIntent = intent + return '' + }) + if (lastIntent !== undefined) { + item.content = stripped + group.intent = lastIntent + } +} + function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { lastItem.content += content + extractGroupIntents(group, lastItem) } else { - group.items.push({ type: 'text', content }) + const item = { type: 'text' as const, content } + group.items.push(item) + extractGroupIntents(group, item) } } From 9aaa42810b569ea63076f5a0a61400ae8316122a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:37:41 -0700 Subject: [PATCH 031/135] Fall back to the live tool title for the agent card status line Persisted data proved tool-first subagents (grok search agents) emit zero prose, so intent tags never stream no matter what the prompt says. The collapsed card now always narrates: the agent's own tag when present, else the latest tool's display title while the lane is live. --- .../components/agent-group/agent-group.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 77a7eb18032..cdabd839294 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -119,7 +119,18 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel + // Status line preference: the agent's own tag, else the latest + // tool's display title while the lane is live — so the collapsed card always + // narrates activity even for models that skip prose entirely. + const latestToolTitle = (() => { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] + if (it.type === 'tool') return it.data.displayTitle || String(it.data.toolName ?? '') + } + return undefined + })() + const status = intent ?? (isLaneOpen ? latestToolTitle : undefined) + const headerText = status ? `${agentLabel} — ${status}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() From 95153c0c4174ab5317edd0d930349f44943dc514 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:45:24 -0700 Subject: [PATCH 032/135] Catch subagent tags in the server relay The relay's subagent text handler now runs the split-safe intent extraction as chunks stream: the latest complete tag is stamped onto the lane's persisted subagent block (subagentIntent) and stripped from the stored prose, so live, persisted, and replayed views all agree. Per-lane carry handles tags split across chunks; a never-closing tag flushes back as plain text. --- .../request/handlers/text-intent.test.ts | 57 ++++++++++++++ apps/sim/lib/copilot/request/handlers/text.ts | 77 ++++++++++++++++++- apps/sim/lib/copilot/request/types.ts | 2 + 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/copilot/request/handlers/text-intent.test.ts diff --git a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts new file mode 100644 index 00000000000..d4e4abc430e --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { handleTextEvent } from '@/lib/copilot/request/handlers/text' +import type { StreamingContext } from '@/lib/copilot/request/types' + +function laneTextEvent(text: string) { + return { + type: 'text', + payload: { channel: 'assistant', text }, + scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' }, + } as never +} + +function makeContext(): StreamingContext { + return { + contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }], + subAgentContent: {}, + subagentThinkingBlocks: new Map(), + isInThinkingBlock: false, + } as unknown as StreamingContext +} + +describe('subagent intent extraction (server relay)', () => { + it('strips a split tag and stamps the lane block intent', async () => { + const ctx = makeContext() + const handler = handleTextEvent('subagent') + await handler(laneTextEvent('Drafting outline\nStarting now.'), + ctx, + {} as never, + {} as never + ) + + const start = ctx.contentBlocks.find((b) => b.type === 'subagent') + expect(start?.subagentIntent).toBe('Drafting outline') + const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text') + expect(text?.content).toBe('Starting now.') + expect(ctx.subAgentContent['tc-1']).toBe('Starting now.') + }) + + it('takes the latest tag and keeps surrounding prose', async () => { + const ctx = makeContext() + const handler = handleTextEvent('subagent') + await handler( + laneTextEvent('aOnebTwoc'), + ctx, + {} as never, + {} as never + ) + const start = ctx.contentBlocks.find((b) => b.type === 'subagent') + expect(start?.subagentIntent).toBe('Two') + expect(ctx.subAgentContent['tc-1']).toBe('abc') + }) +}) diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 8f110a82b28..9aaaae2094f 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -1,4 +1,5 @@ import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, @@ -8,6 +9,72 @@ import { getScopedSpanIdentity, } from './types' +const INTENT_OPEN = '' +const INTENT_CLOSE = '' +/** A tag that never closes within this many chars flushes back as plain text. */ +const INTENT_CARRY_MAX = 240 + +function partialSuffixLen(buf: string, token: string): number { + const max = Math.min(buf.length, token.length - 1) + for (let len = max; len > 0; len--) { + if (token.startsWith(buf.slice(buf.length - len))) return len + } + return 0 +} + +/** + * Streams one subagent lane's text through the protocol: complete + * tags are removed from the persisted prose and the latest one is returned; + * a tag split across chunks is carried (per lane) until its close arrives. + */ +function filterLaneIntent( + context: StreamingContext, + laneId: string, + incoming: string +): { text: string; intent?: string } { + const carries = (context.subagentIntentCarry ??= {}) + let buf = (carries[laneId] ?? '') + incoming + carries[laneId] = '' + let out = '' + let intent: string | undefined + while (buf) { + const openIdx = buf.indexOf(INTENT_OPEN) + if (openIdx === -1) { + const keep = partialSuffixLen(buf, INTENT_OPEN) + out += keep ? buf.slice(0, buf.length - keep) : buf + if (keep) carries[laneId] = buf.slice(buf.length - keep) + break + } + out += buf.slice(0, openIdx) + const rest = buf.slice(openIdx) + const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) + if (closeIdx === -1) { + if (rest.length > INTENT_CARRY_MAX) { + out += rest + } else { + carries[laneId] = rest + } + break + } + const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim() + if (inner) intent = inner + buf = rest.slice(closeIdx + INTENT_CLOSE.length) + if (buf.startsWith('\n')) buf = buf.slice(1) + } + return intent !== undefined ? { text: out, intent } : { text: out } +} + +/** Stamps the latest intent onto the lane's open `subagent` start block. */ +function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void { + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if (b.type === 'subagent' && b.parentToolCallId === laneId) { + b.subagentIntent = intent + return + } + } +} + export function handleTextEvent(scope: ToolScope): StreamHandler { return (event, context) => { if (event.type !== 'text') { @@ -48,11 +115,17 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (context.isInThinkingBlock) { flushThinkingBlock(context) } + // Catch the lane's protocol server-side: the latest tag becomes + // the persisted subagent block's status and the stored prose is stripped, + // so every surface (live, persisted, replay) agrees on both. + const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk) + if (intent) stampLaneIntent(context, parentToolCallId, intent) + if (!cleanChunk) return context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + chunk + (context.subAgentContent[parentToolCallId] || '') + cleanChunk addContentBlock(context, { type: 'subagent_text', - content: chunk, + content: cleanChunk, parentToolCallId, ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index eaaf50d95c8..3983b6f82cb 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -150,6 +150,8 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map + /** Per-lane carry for an tag split across streamed chunks. */ + subagentIntentCarry?: Record isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From 99397debde924f3c5288107ad4ca2f3c8d1d380b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 12:48:38 -0700 Subject: [PATCH 033/135] Drop the tool-title fallback: the status line is the agent's intent With the intent protocol now injected into every spawn's task message, agents open with an tag; the card shows that narration or nothing. --- .../components/agent-group/agent-group.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index cdabd839294..6d841febfbd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -119,18 +119,10 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // Status line preference: the agent's own tag, else the latest - // tool's display title while the lane is live — so the collapsed card always - // narrates activity even for models that skip prose entirely. - const latestToolTitle = (() => { - for (let i = items.length - 1; i >= 0; i--) { - const it = items[i] - if (it.type === 'tool') return it.data.displayTitle || String(it.data.toolName ?? '') - } - return undefined - })() - const status = intent ?? (isLaneOpen ? latestToolTitle : undefined) - const headerText = status ? `${agentLabel} — ${status}` : agentLabel + // The status line is the agent's own narration — no tool-title + // fallback: the task-message protocol reminder makes every agent open with + // an intent tag, so a bare label means the run has not produced one yet. + const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() From af516f3f58afc1ccc85665381d3a5a8ef87f3c7f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:01:28 -0700 Subject: [PATCH 034/135] Replace intents with live tool-title status lines on agent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intent parsing is fully removed (turn model, relay handler, persistence fields, group extraction). The collapsed card's status is the latest tool call in its RUNNING phrasing — never the completed rewrite, which stays in the expanded log. Parallel tools show the most recently started still-running title with a +N for concurrent siblings; between rounds the last title stays frozen; a closed lane shows the bare name. Nested agent cards compute their own status recursively from their own items. --- .../components/agent-group/agent-group.tsx | 34 +++++--- .../message-content/message-content.tsx | 32 +------- .../home/hooks/stream/turn-model-serialize.ts | 2 - .../home/hooks/stream/turn-model.test.ts | 40 ---------- .../home/hooks/stream/turn-model.ts | 68 +--------------- .../app/workspace/[workspaceId]/home/types.ts | 2 - apps/sim/lib/copilot/chat/display-message.ts | 1 - .../sim/lib/copilot/chat/persisted-message.ts | 7 -- .../request/handlers/text-intent.test.ts | 57 -------------- apps/sim/lib/copilot/request/handlers/text.ts | 77 +------------------ apps/sim/lib/copilot/request/types.ts | 4 - 11 files changed, 28 insertions(+), 296 deletions(-) delete mode 100644 apps/sim/lib/copilot/request/handlers/text-intent.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 6d841febfbd..c481523e9fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -21,8 +21,6 @@ export interface NestedAgentGroup { id: string agentName: string agentLabel: string - /** The agent's latest tag — the collapsed row's live status. */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -36,8 +34,6 @@ export type AgentGroupItem = interface AgentGroupProps { agentName: string agentLabel: string - /** The agent's latest tag — shown inline after the label. */ - intent?: string items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean @@ -111,7 +107,6 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean { export function AgentGroup({ agentName, agentLabel, - intent, items, isDelegating = false, isStreaming = false, @@ -119,10 +114,30 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) - // The status line is the agent's own narration — no tool-title - // fallback: the task-message protocol reminder makes every agent open with - // an intent tag, so a bare label means the run has not produced one yet. - const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel + // Collapsed status line: the latest tool call, always in its RUNNING + // phrasing — it never flips to the completed rewrite (that lives in the + // expanded log). With parallel tools, the most recently started + // still-running one wins, with a +N for its running siblings; between + // rounds the last tool's title stays frozen; a closed lane shows the bare + // name. + const status = (() => { + if (!isLaneOpen) return undefined + let running: string | undefined + let runningCount = 0 + let lastAny: string | undefined + for (const it of items) { + if (it.type !== 'tool') continue + const title = it.data.displayTitle || String(it.data.toolName ?? '') + lastAny = title + if (it.data.status === ToolCallStatus.executing) { + running = title + runningCount += 1 + } + } + if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running + return lastAny + })() + const headerText = status ? `${agentLabel} — ${status}` : agentLabel const hasItems = items.length > 0 const resolved = isAgentGroupResolved(items) const browserAgentAvailable = isBrowserAgentAvailable() @@ -220,7 +235,6 @@ export function AgentGroup({ tag (parsed upstream from its text). */ - intent?: string items: AgentGroupItem[] isDelegating: boolean isOpen: boolean @@ -224,37 +222,12 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { * Streamed chunks and resume legs are concatenated verbatim, so a token split * like `v2.` + `1` is never mutated. */ -const INTENT_TAG_RE = /([\s\S]*?)<\/intent>\n?/g - -/** - * Extracts complete tags from a group's accumulated text: the last - * tag becomes the group's live status line and every complete tag is removed - * from the rendered prose. Runs on the accumulated buffer each append, so a - * tag split across streamed chunks is picked up once its close arrives — - * covering the span path, the legacy block path, and persisted reloads alike. - */ -function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void { - let lastIntent: string | undefined - const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => { - const intent = inner.trim() - if (intent) lastIntent = intent - return '' - }) - if (lastIntent !== undefined) { - item.content = stripped - group.intent = lastIntent - } -} - function appendTextItem(group: AgentGroupSegment, content: string): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { lastItem.content += content - extractGroupIntents(group, lastItem) } else { - const item = { type: 'text' as const, content } - group.items.push(item) - extractGroupIntents(group, item) + group.items.push({ type: 'text', content }) } } @@ -408,7 +381,6 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (block.endedAt !== undefined) { // Persisted backend path: the lane was stamped closed (endedAt) without // a separate subagent_end block (the Sim backend stamps endedAt only; @@ -653,7 +625,6 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { groupsByKey.delete(groupKey('mothership', undefined)) const { group: g } = ensureGroup(key, block.parentToolCallId) if (block.subagentName) g.agentLabel = block.subagentName - if (block.subagentIntent) g.intent = block.subagentIntent if (inheritedDelegation) g.isDelegating = true g.isOpen = true activeGroupKey = resolveGroupKey(key, block.parentToolCallId) @@ -985,7 +956,6 @@ function MessageContentInner({ key={segment.id} agentName={segment.agentName} agentLabel={segment.agentLabel} - intent={segment.intent} items={segment.items} isDelegating={segment.isDelegating} isStreaming={isStreaming} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 4a61a120c4a..6088ee0419a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -168,7 +168,6 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { type: 'subagent', content: node.agentId, ...(node.displayName ? { subagentName: node.displayName } : {}), - ...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}), spanId: node.spanId, parentSpanId: node.parentSpanId, ...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}), @@ -272,7 +271,6 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { data: { ...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}), ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), }, }, scopeFor(block), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 0af21ac7e24..85c0b420e73 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -260,46 +260,6 @@ describe('reduceEvent — subagent lifecycle', () => { expect(agent(m, 'S1').displayName).toBe('Pricing research') }) - it('parses intent tags out of subagent text into the agent status', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'Drafting chapter outline\nStarting on the outline now.'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - expect(text && text.kind === 'text' ? text.text : '').not.toContain('') - expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline') - }) - - it('handles an intent tag split across deltas and takes the latest tag', () => { - const textEv = (seq: number, text: string) => - envelope( - seq, - 'text', - { channel: 'assistant', text }, - { lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' } - ) - const m = apply([ - spanStart(1, 'S1', 'file', 'tc-f'), - textEv(2, 'ok. Writing first chap'), - textEv(4, 'tertext after. Reviewing draft'), - ]) - expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft') - const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1') - const rendered = text && text.kind === 'text' ? text.text : '' - expect(rendered).toContain('ok. ') - expect(rendered).toContain('text after. ') - expect(rendered).not.toContain('intent>') - }) - it('settles an agent error when span end carries an error', () => { const m = apply([ spanStart(1, 'S1', 'file', 'tc-file'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 820b41884ec..d016e22c9af 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -87,10 +87,6 @@ export interface AgentNode extends NodeBase { triggerToolCallId?: string /** Orchestrator-chosen display name for this delegation (falls back to the agent label). */ displayName?: string - /** The agent's latest tag — the collapsed card's live status line. */ - currentIntent?: string - /** Streaming carry for an intent tag split across text deltas (never serialized). */ - intentCarry?: string status: NodeStatus /** Wire seq at which the run terminated (span end), for ordering the close marker. */ endSeq?: number @@ -301,57 +297,6 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void { closeOpenText(model, spanId, 'thinking', atMs) } -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -/** Length of the longest buf suffix that could still grow into `token`. */ -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams a subagent's assistant text through the protocol: complete - * tags update the owning agent's currentIntent and are removed from the prose; - * a tag split across deltas is carried until its close arrives. The returned - * string is what the transcript should render. - */ -function filterIntentText(owner: AgentNode, incoming: string): string { - let buf = (owner.intentCarry ?? '') + incoming - owner.intentCarry = '' - let out = '' - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) owner.intentCarry = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - owner.intentCarry = rest - } - break - } - const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (intent) owner.currentIntent = intent - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return out -} - function appendText( model: TurnModel, spanId: string, @@ -517,15 +462,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve case MothershipStreamV1EventType.text: { const payload = envelope.payload ensureSubagentLane(model, spanId, scope, seq, tsMs) - let text = payload.text - if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') { - const ownerId = model.agentBySpanId.get(spanId) - const owner = ownerId ? model.nodes.get(ownerId) : undefined - if (owner && owner.kind === 'agent') { - text = filterIntentText(owner, text) - } - } - appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs) + appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs) break } case MothershipStreamV1EventType.tool: { @@ -629,7 +566,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId) const agentId = asString(payload.agent) ?? scope?.agentId ?? '' const displayName = asString(data?.name) - const restoredIntent = asString(data?.intent) const resolvedSpanId = scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`) const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN @@ -649,7 +585,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve // while this start's payload.agent is the authoritative lane owner. if (agentId && existing.agentId !== agentId) existing.agentId = agentId if (displayName) existing.displayName = displayName - if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent if (!existing.triggerToolCallId && triggerToolCallId) { existing.triggerToolCallId = triggerToolCallId } @@ -672,7 +607,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ...(tsMs !== undefined ? { startedAtMs: tsMs } : {}), ...(triggerToolCallId ? { triggerToolCallId } : {}), ...(displayName ? { displayName } : {}), - ...(restoredIntent ? { currentIntent: restoredIntent } : {}), } model.nodes.set(node.id, node) model.order.push(node.id) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 2f0124bacfc..eedb402ba87 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -116,8 +116,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string - /** The agent's latest tag at serialization time — the collapsed card's status line. */ - subagentIntent?: string toolCall?: ToolCallInfo options?: OptionItem[] timestamp?: number diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index 91ce4906570..28a348a5837 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -96,7 +96,6 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi type: ContentBlockType.subagent, content: block.content, ...(block.name ? { subagentName: block.name } : {}), - ...(block.intent ? { subagentIntent: block.intent } : {}), } case MothershipStreamV1EventType.complete: if (block.status === MothershipStreamV1CompletionStatus.cancelled) { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 9e8c9ba26e7..c57e49b5a85 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -55,8 +55,6 @@ export interface PersistedContentBlock { content?: string /** Orchestrator-chosen display name on a subagent start block. */ name?: string - /** The agent's latest tag at persistence time. */ - intent?: string toolCall?: PersistedToolCall timestamp?: number endedAt?: number @@ -250,7 +248,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } case 'subagent_text': return { @@ -445,8 +442,6 @@ interface RawBlock { /** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */ name?: string subagentName?: string - intent?: string - subagentIntent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -515,7 +510,6 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { } if (block.agent) result.agent = block.agent if (block.name) result.name = block.name - if (block.intent) result.intent = block.intent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -598,7 +592,6 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: block.content, ...(block.subagentName ? { name: block.subagentName } : {}), - ...(block.subagentIntent ? { intent: block.subagentIntent } : {}), } } diff --git a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts b/apps/sim/lib/copilot/request/handlers/text-intent.test.ts deleted file mode 100644 index d4e4abc430e..00000000000 --- a/apps/sim/lib/copilot/request/handlers/text-intent.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { handleTextEvent } from '@/lib/copilot/request/handlers/text' -import type { StreamingContext } from '@/lib/copilot/request/types' - -function laneTextEvent(text: string) { - return { - type: 'text', - payload: { channel: 'assistant', text }, - scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' }, - } as never -} - -function makeContext(): StreamingContext { - return { - contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }], - subAgentContent: {}, - subagentThinkingBlocks: new Map(), - isInThinkingBlock: false, - } as unknown as StreamingContext -} - -describe('subagent intent extraction (server relay)', () => { - it('strips a split tag and stamps the lane block intent', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler(laneTextEvent('Drafting outline\nStarting now.'), - ctx, - {} as never, - {} as never - ) - - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Drafting outline') - const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text') - expect(text?.content).toBe('Starting now.') - expect(ctx.subAgentContent['tc-1']).toBe('Starting now.') - }) - - it('takes the latest tag and keeps surrounding prose', async () => { - const ctx = makeContext() - const handler = handleTextEvent('subagent') - await handler( - laneTextEvent('aOnebTwoc'), - ctx, - {} as never, - {} as never - ) - const start = ctx.contentBlocks.find((b) => b.type === 'subagent') - expect(start?.subagentIntent).toBe('Two') - expect(ctx.subAgentContent['tc-1']).toBe('abc') - }) -}) diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 9aaaae2094f..8f110a82b28 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -1,5 +1,4 @@ import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, @@ -9,72 +8,6 @@ import { getScopedSpanIdentity, } from './types' -const INTENT_OPEN = '' -const INTENT_CLOSE = '' -/** A tag that never closes within this many chars flushes back as plain text. */ -const INTENT_CARRY_MAX = 240 - -function partialSuffixLen(buf: string, token: string): number { - const max = Math.min(buf.length, token.length - 1) - for (let len = max; len > 0; len--) { - if (token.startsWith(buf.slice(buf.length - len))) return len - } - return 0 -} - -/** - * Streams one subagent lane's text through the protocol: complete - * tags are removed from the persisted prose and the latest one is returned; - * a tag split across chunks is carried (per lane) until its close arrives. - */ -function filterLaneIntent( - context: StreamingContext, - laneId: string, - incoming: string -): { text: string; intent?: string } { - const carries = (context.subagentIntentCarry ??= {}) - let buf = (carries[laneId] ?? '') + incoming - carries[laneId] = '' - let out = '' - let intent: string | undefined - while (buf) { - const openIdx = buf.indexOf(INTENT_OPEN) - if (openIdx === -1) { - const keep = partialSuffixLen(buf, INTENT_OPEN) - out += keep ? buf.slice(0, buf.length - keep) : buf - if (keep) carries[laneId] = buf.slice(buf.length - keep) - break - } - out += buf.slice(0, openIdx) - const rest = buf.slice(openIdx) - const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length) - if (closeIdx === -1) { - if (rest.length > INTENT_CARRY_MAX) { - out += rest - } else { - carries[laneId] = rest - } - break - } - const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim() - if (inner) intent = inner - buf = rest.slice(closeIdx + INTENT_CLOSE.length) - if (buf.startsWith('\n')) buf = buf.slice(1) - } - return intent !== undefined ? { text: out, intent } : { text: out } -} - -/** Stamps the latest intent onto the lane's open `subagent` start block. */ -function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void { - for (let i = context.contentBlocks.length - 1; i >= 0; i--) { - const b = context.contentBlocks[i] - if (b.type === 'subagent' && b.parentToolCallId === laneId) { - b.subagentIntent = intent - return - } - } -} - export function handleTextEvent(scope: ToolScope): StreamHandler { return (event, context) => { if (event.type !== 'text') { @@ -115,17 +48,11 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (context.isInThinkingBlock) { flushThinkingBlock(context) } - // Catch the lane's protocol server-side: the latest tag becomes - // the persisted subagent block's status and the stored prose is stripped, - // so every surface (live, persisted, replay) agrees on both. - const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk) - if (intent) stampLaneIntent(context, parentToolCallId, intent) - if (!cleanChunk) return context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + cleanChunk + (context.subAgentContent[parentToolCallId] || '') + chunk addContentBlock(context, { type: 'subagent_text', - content: cleanChunk, + content: chunk, parentToolCallId, ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 3983b6f82cb..35127f3edf9 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -82,8 +82,6 @@ export interface ContentBlock { subagent?: string /** Orchestrator-chosen display name for a `subagent` start block. */ subagentName?: string - /** The agent's latest tag. */ - subagentIntent?: string /** * Deterministic agent-run identity. `spanId` is the stable per-invocation id * of the subagent that produced the block; `parentSpanId` links it to the run @@ -150,8 +148,6 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map - /** Per-lane carry for an tag split across streamed chunks. */ - subagentIntentCarry?: Record isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From 1e51860a20d1115017b5626d0141de7494f6ad09 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:15:44 -0700 Subject: [PATCH 035/135] Keep the main Sim lane live-expanded; collapse only real subagent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mothership group is the turn's own narration, not a delegation card — collapsing it hid main-lane text and tools until manual expand, which read as mis-ordered streaming while async subagents interleaved. It keeps the original live-expand behavior and no status suffix. --- .../components/agent-group/agent-group.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index c481523e9fa..8892141a74e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -114,6 +114,7 @@ export function AgentGroup({ isLaneOpen = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) + const isMainAgent = agentName === 'mothership' // Collapsed status line: the latest tool call, always in its RUNNING // phrasing — it never flips to the completed rewrite (that lives in the // expanded log). With parallel tools, the most recently started @@ -121,7 +122,7 @@ export function AgentGroup({ // rounds the last tool's title stays frozen; a closed lane shows the bare // name. const status = (() => { - if (!isLaneOpen) return undefined + if (isMainAgent || !isLaneOpen) return undefined let running: string | undefined let runningCount = 0 let lastAny: string | undefined @@ -147,12 +148,13 @@ export function AgentGroup({ const isWorking = !activeBrowserTakeover && ((isDelegating && !resolved) || (isStreaming && isLaneOpen)) - // Agent groups never auto-expand: the collapsed row IS the live view — the - // label plus the agent's latest tag, replaced inline as it works. - // Expanding is a deliberate user action (the toggle below); only an - // outstanding permission prompt or a browser hand-back forces the group - // open, because the turn cannot proceed while they wait off-screen. - const autoExpanded = false + // SUBAGENT groups never auto-expand: the collapsed row IS the live view — + // label plus latest running tool title. Expanding is a deliberate user + // action; only a pending permission prompt or a browser hand-back forces + // one open. The MAIN lane ("Sim") is not a delegation card: its narration + // and tool calls are the turn itself, so it keeps the original live-expand + // behavior (open while streaming/current, settles when superseded). + const autoExpanded = isMainAgent && isStreaming && (isCurrentSection || isLaneOpen || !resolved) const [manualExpanded, setManualExpanded] = useState(null) const [expandedTakeoverId, setExpandedTakeoverId] = useState(null) // An outstanding permission prompt overrides a manual collapse: the turn From b98ef9b70aa28c7cf2bcd5d5cb215dddbd17b7cf Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:18:36 -0700 Subject: [PATCH 036/135] Persist subagent lane lifecycle blocks from the span handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane-scoped span events route to the span handler, which only recorded trace side effects — no subagent start block was ever persisted (verified: a seven-agent run stored 104 blocks with zero starts). Grouping then fell back to keying lane content by agent NAME, so a respawned agent of the same type merged invisibly into the first one's card until it resolved. The handler now persists the start block (spanId-keyed and deduped, carrying the display name) and stamps endedAt on close, giving every invocation its own card. --- apps/sim/lib/copilot/request/handlers/span.ts | 41 +++++++++++++++++++ apps/sim/lib/copilot/request/types.ts | 2 + 2 files changed, 43 insertions(+) diff --git a/apps/sim/lib/copilot/request/handlers/span.ts b/apps/sim/lib/copilot/request/handlers/span.ts index 2ad6dcf3382..ba2fba1caac 100644 --- a/apps/sim/lib/copilot/request/handlers/span.ts +++ b/apps/sim/lib/copilot/request/handlers/span.ts @@ -3,6 +3,7 @@ import { MothershipStreamV1SpanPayloadKind, } from '@/lib/copilot/generated/mothership-stream-v1' import type { StreamHandler } from './types' +import { addContentBlock } from './types' /** * Mirror Go-emitted span lifecycle events onto the Sim-side TraceCollector. @@ -34,6 +35,46 @@ export const handleSpanEvent: StreamHandler = (event, context) => { // (e.g. two parallel `research` subagents) get distinct trace spans. Fall // back to agent:parentToolCallId for legacy events that predate span ids. const traceKey = event.scope?.spanId || `${scopeAgent}:${event.scope?.parentToolCallId || ''}` + // Persist the lane's lifecycle markers. Without a `subagent` start block, + // the transcript parser falls back to grouping lane content by agent NAME + // — so a respawned agent of the same type (a second concurrent `search`) + // silently merges into the first one's card and appears "missing" until + // that one resolves. Keyed and deduped by spanId, so every invocation — + // including same-type concurrent respawns — gets its own group. + const startData = payload.data as Record | undefined + if (evt === MothershipStreamV1SpanLifecycleEvent.start) { + context.openSubagentSpans ??= new Set() + if (!context.openSubagentSpans.has(traceKey)) { + context.openSubagentSpans.add(traceKey) + addContentBlock(context, { + type: 'subagent', + content: scopeAgent, + ...(event.scope?.parentToolCallId + ? { parentToolCallId: event.scope.parentToolCallId } + : {}), + ...(event.scope?.spanId ? { spanId: event.scope.spanId } : {}), + ...(event.scope?.parentSpanId ? { parentSpanId: event.scope.parentSpanId } : {}), + ...(typeof startData?.name === 'string' && startData.name + ? { subagentName: startData.name } + : {}), + }) + } + } else if (evt === MothershipStreamV1SpanLifecycleEvent.end) { + if (context.openSubagentSpans?.has(traceKey)) { + context.openSubagentSpans.delete(traceKey) + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const b = context.contentBlocks[i] + if ( + b.type === 'subagent' && + b.endedAt === undefined && + (b.spanId || '') === (event.scope?.spanId || '') + ) { + b.endedAt = Date.now() + break + } + } + } + } if (evt === MothershipStreamV1SpanLifecycleEvent.start) { const span = context.trace.startSpan(`subagent:${scopeAgent}`, 'go.subagent', { agent: scopeAgent, diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 35127f3edf9..f11a9b15a4d 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -148,6 +148,8 @@ export interface StreamingContext { * block. Per-lane keying keeps each subagent's reasoning intact. */ subagentThinkingBlocks: Map + /** Span ids whose lane start block has been persisted (dedupe across replays). */ + openSubagentSpans?: Set isInThinkingBlock: boolean subAgentContent: Record subAgentToolCalls: Record From 19e5efdfe64f79fff9a0bf10265ed37bdaa9ae1b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 13:21:13 -0700 Subject: [PATCH 037/135] Name agents in orchestration titles; '+ n more' overflow format wait/tail/steer/interrupt titles humanize the slugified agent ids back to their display names ('Waiting for the first of Digest Workflow Build + 4 more'), and the agent card's parallel-tool suffix uses the same '+ n more' format. --- .../components/agent-group/agent-group.tsx | 2 +- apps/sim/lib/copilot/tools/tool-display.ts | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 8892141a74e..b0aa7beeaac 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -135,7 +135,7 @@ export function AgentGroup({ runningCount += 1 } } - if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running + if (running) return runningCount > 1 ? `${running} + ${runningCount - 1} more` : running return lastAny })() const headerText = status ? `${agentLabel} — ${status}` : agentLabel diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index ad08842187d..1b5a85f435a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -610,16 +610,26 @@ function waitTitle(args: ToolArgs): string { return formatWaitTitle(requestedWaitSeconds(args), stringArg(args, 'reason')) } -/** Title for a wait_agents sleep, naming the agent(s) being collected and honoring mode "any". */ +/** + * An async agent id is its slugified display name plus a sequence suffix + * ("digest-workflow-build-4"); recover the human name for titles. + */ +function humanizeAgentId(id: string): string { + const words = id.replace(/-\d+$/, '').split('-').filter(Boolean) + if (words.length === 0) return id + return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') +} + +/** Title for a wait_agents sleep, naming the agents and honoring mode "any". */ function waitAgentsTitle(args: ToolArgs): string { const raw = args?.agent_ids const ids = Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [] + const names = ids.map(humanizeAgentId) const anyMode = stringArg(args, 'mode') === 'any' - if (ids.length === 1) return `Waiting for ${ids[0]}` - if (ids.length > 1) { - return anyMode - ? `Waiting for the first of ${ids.length} agents` - : `Waiting for ${ids.length} agents` + if (names.length === 1) return `Waiting for ${names[0]}` + if (names.length > 1) { + const listed = `${names[0]} + ${names.length - 1} more` + return anyMode ? `Waiting for the first of ${listed}` : `Waiting for ${listed}` } return 'Waiting for agents' } @@ -732,11 +742,11 @@ export function getToolDisplayTitle(name: string, args?: Record case 'wait_agents': return waitAgentsTitle(args) case 'tail_agent': - return `Checking on ${stringArg(args, 'agent_id') || 'agent'}` + return `Checking on ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'steer_agent': - return `Steering ${stringArg(args, 'agent_id') || 'agent'}` + return `Steering ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'interrupt_agent': - return `Stopping ${stringArg(args, 'agent_id') || 'agent'}` + return `Stopping ${humanizeAgentId(stringArg(args, 'agent_id')) || 'agent'}` case 'terminal': return terminalTitle(args) // The surface used to be one tool per operation. Conversations recorded From e7a0d1c4d195df3cd79829b804f5fb0b95b266e6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 14:17:08 -0700 Subject: [PATCH 038/135] Harden in-band tool execution and resources --- .../api/copilot/tools/execute/route.test.ts | 106 ++++++++++++++++++ .../app/api/copilot/tools/execute/route.ts | 103 +++++++++++++++-- .../generated/mothership-stream-v1-schema.ts | 3 + .../copilot/generated/mothership-stream-v1.ts | 1 + .../copilot/request/handlers/handlers.test.ts | 27 +++++ apps/sim/lib/copilot/request/handlers/tool.ts | 10 +- .../sim/lib/copilot/request/handlers/types.ts | 2 + .../sim/lib/copilot/request/tools/executor.ts | 19 +++- .../tools/resolved-secret-result.test.ts | 26 +++++ .../request/tools/resolved-secret-result.ts | 42 +++++-- .../tools/registry/server-tool-adapter.ts | 3 +- .../copilot/tools/server/files/create-file.ts | 58 ++++++---- .../tools/server/image/generate-image.ts | 14 +++ .../tools/server/media/generate-audio.ts | 11 ++ .../tools/server/media/generate-video.ts | 11 ++ .../lib/copilot/vfs/resource-writer.test.ts | 54 +++++++++ apps/sim/lib/copilot/vfs/resource-writer.ts | 59 ++++++---- 17 files changed, 481 insertions(+), 68 deletions(-) create mode 100644 apps/sim/app/api/copilot/tools/execute/route.test.ts diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts new file mode 100644 index 00000000000..e1fc2d39141 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const { mockCheckInternalApiKey, mockPrepareEnvironmentContext, mockHandler } = vi.hoisted(() => ({ + mockCheckInternalApiKey: vi.fn(), + mockPrepareEnvironmentContext: vi.fn(), + mockHandler: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + checkInternalApiKey: mockCheckInternalApiKey, +})) + +vi.mock('@/lib/copilot/environment-context', () => ({ + prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext, +})) + +vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({ + createServerToolHandler: () => mockHandler, +})) + +vi.mock('@/lib/copilot/request/tools/resources', () => ({ + handleResourceSideEffects: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: ( + _headers: Headers, + _span: string, + _attrs: undefined, + fn: (span: { setAttributes: () => void }) => Promise + ) => fn({ setAttributes: () => {} }), +})) + +import { POST } from '@/app/api/copilot/tools/execute/route' + +function makeRequest(body: Record): Request { + return new Request('http://localhost/api/copilot/tools/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const BASE_BODY = { + toolCallId: 'call-1', + toolName: 'read', + params: { path: 'files/a.md' }, + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + messageId: 'msg-1', +} + +describe('POST /api/copilot/tools/execute (in-band)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInternalApiKey.mockReturnValue({ success: true }) + // A fresh, complete registry per test: the module-level turn cache is keyed + // by messageId, so each test uses a distinct messageId to avoid cross-test + // cache hits. + mockPrepareEnvironmentContext.mockImplementation(async () => ({ + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), + })) + }) + + it('threads a per-call registry fork into the handler and returns the projected result', async () => { + mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-fork' }) as never) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } }) + + const [, handlerContext] = mockHandler.mock.calls[0] + expect(handlerContext.resolvedSecretTraceRegistry).toBeInstanceOf(ResolvedSecretTraceRegistry) + expect(handlerContext.userId).toBe('user-1') + expect(handlerContext.copilotToolExecution).toBe(true) + }) + + it('keeps a clean tool failure message intact when the registry is available', async () => { + mockHandler.mockResolvedValue({ success: false, error: 'File not found: files/a.md' }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-clean-error' }) as never) + const body = await res.json() + expect(body.success).toBe(false) + expect(body.error).toBe('File not found: files/a.md') + }) + + it('withholds results when no egress registry can be built', async () => { + mockPrepareEnvironmentContext.mockRejectedValue(new Error('env unavailable')) + mockHandler.mockResolvedValue({ success: true, output: { content: 'sensitive' } }) + const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-no-registry' }) as never) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + it('reuses one turn registry across calls that share a messageId', async () => { + mockHandler.mockResolvedValue({ success: true, output: {} }) + await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-shared' }) as never) + await POST( + makeRequest({ ...BASE_BODY, toolCallId: 'call-2', messageId: 'msg-shared' }) as never + ) + expect(mockPrepareEnvironmentContext).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index 82c43929845..a2e4c3b73c6 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -3,17 +3,64 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' +import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { + inspectToolResultForCopilot, + projectToolErrorMessageForCopilot, +} from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import type { ToolCallResult } from '@/lib/copilot/request/types' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotToolExecuteInternalAPI') +/** + * In-band calls are stateless one-offs, but the turn they serve is not: secret + * provenance activated by one tool call must stay visible to the next call's + * egress projection, exactly as the request-lifecycle registry accumulates + * across a turn. Keyed by the turn (messageId) so one background lane shares + * one registry; TTL-evicted since nothing signals turn end on this route. + */ +const TURN_REGISTRY_TTL_MS = 10 * 60 * 1000 +const TURN_REGISTRY_CACHE_MAX = 256 +const turnRegistryCache = new Map< + string, + { registry: ResolvedSecretTraceRegistry; expiresAt: number } +>() + +async function getTurnEgressRegistry( + userId: string, + workspaceId: string | undefined, + messageId: string | undefined +): Promise { + const key = `${userId}\u0000${workspaceId ?? ''}\u0000${messageId ?? ''}` + const now = Date.now() + const hit = turnRegistryCache.get(key) + if (hit && hit.expiresAt > now) { + hit.expiresAt = now + TURN_REGISTRY_TTL_MS + return hit.registry + } + const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId) + for (const [cachedKey, cached] of turnRegistryCache) { + if (cached.expiresAt <= now) turnRegistryCache.delete(cachedKey) + } + if (turnRegistryCache.size >= TURN_REGISTRY_CACHE_MAX) { + const oldest = turnRegistryCache.keys().next().value + if (oldest !== undefined) turnRegistryCache.delete(oldest) + } + turnRegistryCache.set(key, { + registry: environmentContext.resolvedSecretTraceRegistry, + expiresAt: now + TURN_REGISTRY_TTL_MS, + }) + return environmentContext.resolvedSecretTraceRegistry +} + // POST /api/copilot/tools/execute — internal (Go → Sim) in-band execution of // one sim-server tool announced on a LIVE mothership turn. This is what lets // background (async) subagents — and the main lane while background agents are @@ -21,6 +68,14 @@ const logger = createLogger('CopilotToolExecuteInternalAPI') // synchronously instead of parking the turn, and the tool runs through the // same server tool router the resume driver uses. Trusted server-to-server // only: Go supplies the acting user, proven by the internal API secret. +// +// Results cross a model boundary here just as they do in the resume driver, so +// this route mirrors its provenance discipline: a per-call registry fork feeds +// the handler, the settled result is projected before it returns to Go, and +// the fork is merged back only when the projection was safe and the fork +// stayed complete. Without this, every result crossed unprojected and every +// thrown error was replaced by the opaque "could not be returned safely" +// sentinel (the projection fails closed on a missing registry). export const POST = withRouteHandler((request: NextRequest) => withIncomingGoSpan( request.headers, @@ -59,6 +114,19 @@ export const POST = withRouteHandler((request: NextRequest) => [TraceAttr.UserId]: userId, }) + let toolRegistry: ResolvedSecretTraceRegistry | undefined + let turnRegistry: ResolvedSecretTraceRegistry | undefined + try { + turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId) + toolRegistry = turnRegistry.forkForInputPaths([]) + } catch (err) { + logger.error('In-band egress registry unavailable; results will be withheld', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) + } + try { const handler = createServerToolHandler(toolName) const result = await handler(params, { @@ -71,25 +139,34 @@ export const POST = withRouteHandler((request: NextRequest) => parentToolCallId, userPermission, copilotToolExecution: true, + resolvedSecretTraceRegistry: toolRegistry, }) - if (!result.success) { + const projection = inspectToolResultForCopilot(result, toolRegistry, toolName) + const projected = projection.result + if (projection.safe && toolRegistry?.isComplete() && turnRegistry) { + turnRegistry.mergeToolCallRegistry(toolRegistry) + } + if (!projected.success) { logger.warn('In-band tool execution failed', { toolName, toolCallId, - error: result.error, + error: projected.error, + runtimeSucceeded: result.success, + projectionSafe: projection.safe, }) } if (result.success && chatId) { // Persist created/deleted resources on the chat (file chips, table // links) exactly like the resume driver does. No live event sink // exists for an out-of-band route, so chips surface from the - // persisted chat resources rather than a mid-turn push. + // persisted chat resources rather than a mid-turn push. Side effects + // read the raw result; only model-facing content is projected. const asToolResult = { success: result.success, output: result.output } as ToolCallResult await handleResourceSideEffects( toolName, params, asToolResult, - asToolResult, + { success: projected.success, output: projected.output } as ToolCallResult, chatId, undefined, () => false @@ -102,13 +179,21 @@ export const POST = withRouteHandler((request: NextRequest) => }) } return NextResponse.json({ - success: result.success, - ...(result.output !== undefined ? { output: result.output } : {}), - ...(result.error ? { error: result.error } : {}), + success: projected.success, + ...(projected.output !== undefined ? { output: projected.output } : {}), + ...(projected.error ? { error: projected.error } : {}), }) } catch (err) { - const message = getErrorMessage(err) - logger.error('In-band tool execution threw', { toolName, toolCallId, error: message }) + const message = projectToolErrorMessageForCopilot( + getErrorMessage(err), + toolRegistry, + toolName + ) + logger.error('In-band tool execution threw', { + toolName, + toolCallId, + error: getErrorMessage(err), + }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } } diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts index f6ce59033ee..e7440fb3d0f 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -1339,6 +1339,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { hidden: { type: 'boolean', }, + inbandOwned: { + type: 'boolean', + }, internal: { type: 'boolean', }, diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index 7f4fbd98e19..3b47c736f7e 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -160,6 +160,7 @@ export interface MothershipStreamV1AdditionalPropertiesMap { export interface MothershipStreamV1ToolUI { clientExecutable?: boolean hidden?: boolean + inbandOwned?: boolean internal?: boolean } export interface MothershipStreamV1ToolArgsDeltaEventEnvelope { diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 82762e5d46a..aa4ef86a6a6 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -483,6 +483,33 @@ describe('sse-handlers tool lifecycle', () => { ) }) + it('registers but never dispatches an inband-owned sim tool call', async () => { + // Go executes inband-owned calls itself via /api/copilot/tools/execute; + // dispatching here too ran the tool twice, racing on mutations. + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-inband', + toolName: ReadTool.id, + arguments: { path: 'files/a.md' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + ui: { inbandOwned: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: true } + ) + await sleep(0) + + expect(context.toolCalls.get('tool-inband')).toBeDefined() + expect(executeTool).not.toHaveBeenCalled() + expect(context.pendingToolPromises.has('tool-inband')).toBe(false) + }) + it('preserves primitive tool outputs through async completion persistence', async () => { executeTool.mockResolvedValueOnce({ success: true, output: 'done' }) const onEvent = vi.fn() diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 54bf70d7800..31974d1b489 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -508,11 +508,16 @@ async function handleCallPhase( const readPath = typeof args?.path === 'string' ? args.path : undefined if (toolName === 'read' && readPath?.startsWith('internal/')) return - const { clientExecutable, simExecutable, internal } = ui + const { clientExecutable, simExecutable, internal, inbandOwned } = ui const catalogEntry = getToolEntry(toolName) const isInternal = internal || catalogEntry?.internal === true const staticSimExecuted = isSimExecuted(toolName) - const willDispatch = !isInternal && (staticSimExecuted || simExecutable || clientExecutable) + // Go executes inband-owned calls itself via /api/copilot/tools/execute + // (background lanes, and the main lane while background agents run); the + // event exists only to draw the row. Dispatching it here would run the + // tool a second time, racing the in-band execution on mutations. + const willDispatch = + !isInternal && !inbandOwned && (staticSimExecuted || simExecutable || clientExecutable) logger.info('Tool call routing decision', { toolCallId, toolName, @@ -524,6 +529,7 @@ async function handleCallPhase( simExecutable, staticSimExecuted, internal: isInternal, + inbandOwned, hasPendingPromise: context.pendingToolPromises.has(toolCallId), existingStatus: existing?.status, willDispatch, diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index a7f9d819466..cc3301b4165 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -198,6 +198,7 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { simExecutable: boolean internal: boolean hidden: boolean + inbandOwned: boolean } { const raw = asRecord(data.ui) return { @@ -206,6 +207,7 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { simExecutable: data.executor === MothershipStreamV1ToolExecutor.sim, internal: raw.internal === true, hidden: raw.hidden === true, + inbandOwned: raw.inbandOwned === true, } } diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index f4712a6fffe..7a181bfa4f0 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -610,7 +610,8 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { const copilotResult = inspectToolResultForCopilot( result, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ).result markToolCallCancelled('Request aborted during tool execution') markToolResultSeen(toolCall.id) @@ -726,7 +727,8 @@ async function executeToolAndReportInner( } const projection = inspectToolResultForCopilot( result, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ) const copilotResult = projection.result mergeToolRegistry(projection.safe) @@ -735,6 +737,16 @@ async function executeToolAndReportInner( toolSpan.attributes = { ...toolSpan.attributes, ...summarizeToolResultForSpan(copilotResult), + ...(projection.safe ? {} : { resultWithheld: true }), + } + if (!projection.safe) { + // A withheld SUCCESS otherwise leaves no trace anywhere: the span reads + // ok and the model just sees a bare `{success: true}` with no output. + logger.warn('Tool result withheld by egress projection', { + toolCallId: toolCall.id, + toolName: toolCall.name, + runtimeSucceeded: result.success, + }) } setTerminalToolCallState(toolCall, { @@ -861,7 +873,8 @@ async function executeToolAndReportInner( const thrownMessage = toError(error).message const projection = inspectToolResultForCopilot( { success: false, error: thrownMessage }, - toolExecutionContext.resolvedSecretTraceRegistry + toolExecutionContext.resolvedSecretTraceRegistry, + toolCall.name ) const copilotError = projection.result mergeToolRegistry(projection.safe) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 536ac1adf87..26285b0d7d5 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest' import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolResultForCopilot, + READ_TOOL_RESULT_UNAVAILABLE_ERROR, TOOL_RESULT_UNAVAILABLE_ERROR, + toolResultUnavailableError, } from '@/lib/copilot/request/tools/resolved-secret-result' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -430,4 +432,28 @@ describe('projectToolResultForCopilot', () => { projectToolResultForCopilot({ success: true, output: 'possibly-secret' }, undefined) ).toEqual({ success: true }) }) + + it.each(['read', 'glob', 'grep'])( + 'withholds a read-only %s failure without the mutation-retry warning', + (toolId) => { + const projected = projectToolResultForCopilot( + { success: false, error: 'anything' }, + undefined, + toolId + ) + expect(projected).toEqual({ success: false, error: READ_TOOL_RESULT_UNAVAILABLE_ERROR }) + expect(projected.error).not.toContain('mutation') + } + ) + + it('keeps the mutation-retry warning for withheld mutating-tool failures', () => { + expect( + projectToolResultForCopilot( + { success: false, error: 'anything' }, + undefined, + 'apply_file_edit' + ) + ).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR }) + expect(toolResultUnavailableError(undefined)).toBe(TOOL_RESULT_UNAVAILABLE_ERROR) + }) }) diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts index fdd4584fd1a..f6785f60a8d 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts @@ -5,13 +5,30 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr export const TOOL_RESULT_UNAVAILABLE_ERROR = 'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.' +/** + * Read-only tools carry no mutation-retry hazard, so their withheld results + * must not warn against retrying — that wording makes the model abandon + * harmless reads it could simply try again or work around. + */ +export const READ_TOOL_RESULT_UNAVAILABLE_ERROR = + 'Tool executed, but its result could not be returned safely. The call was read-only, so you may retry it or continue without the result.' + +const READ_ONLY_RESULT_TOOLS = new Set(['read', 'glob', 'grep']) + +/** Chooses the withheld-result message a tool's caller should surface. */ +export function toolResultUnavailableError(toolId?: string): string { + return toolId && READ_ONLY_RESULT_TOOLS.has(toolId) + ? READ_TOOL_RESULT_UNAVAILABLE_ERROR + : TOOL_RESULT_UNAVAILABLE_ERROR +} + function structuralResult(result: ToolExecutionResult): ToolExecutionResult { return { success: result.success === true } } -function omittedResult(result: ToolExecutionResult): ToolExecutionResult { +function omittedResult(result: ToolExecutionResult, toolId?: string): ToolExecutionResult { if (result.success) return { success: true } - return { success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR } + return { success: false, error: toolResultUnavailableError(toolId) } } export type CopilotToolResultProjection = @@ -26,7 +43,8 @@ export type CopilotToolResultProjection = */ export function inspectToolResultForCopilot( result: ToolExecutionResult, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): CopilotToolResultProjection { try { const resultRegistry = registry?.forkForPropagatedEntries() @@ -36,7 +54,7 @@ export function inspectToolResultForCopilot( if (Object.hasOwn(result, 'error')) content.error = result.error const projection = projectResolvedSecretModelJsonContent(content, resultRegistry) if (!projection.safe || !projection.value || typeof projection.value !== 'object') { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } const projectedContent = projection.value as Record @@ -44,7 +62,7 @@ export function inspectToolResultForCopilot( if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output if (Object.hasOwn(projectedContent, 'error')) { if (typeof projectedContent.error !== 'string') { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } projected.error = projectedContent.error } @@ -52,11 +70,11 @@ export function inspectToolResultForCopilot( projected.resources = resources } if (!projected.success && !projected.error) { - projected.error = TOOL_RESULT_UNAVAILABLE_ERROR + projected.error = toolResultUnavailableError(toolId) } return { safe: true, result: projected } } catch { - return { safe: false, result: omittedResult(result) } + return { safe: false, result: omittedResult(result, toolId) } } } @@ -66,15 +84,17 @@ export function inspectToolResultForCopilot( */ export function projectToolResultForCopilot( result: ToolExecutionResult, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): ToolExecutionResult { - return inspectToolResultForCopilot(result, registry).result + return inspectToolResultForCopilot(result, registry, toolId).result } /** Projects an error before post-processing can attach it to application logs or OTel events. */ export function projectToolErrorMessageForCopilot( error: string, - registry: ResolvedSecretTraceRegistry | undefined + registry: ResolvedSecretTraceRegistry | undefined, + toolId?: string ): string { - return projectToolResultForCopilot({ success: false, error }, registry).error ?? '' + return projectToolResultForCopilot({ success: false, error }, registry, toolId).error ?? '' } diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 1c44356651e..181a7d414ce 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -56,7 +56,8 @@ export function createServerToolHandler(toolId: string): ToolHandler { ) const safeMessage = projectToolErrorMessageForCopilot( messageForCopilotApplicationError(error), - context.resolvedSecretTraceRegistry + context.resolvedSecretTraceRegistry, + toolId ) return { success: false, diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 8f36784b1a1..4523d8f1dde 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -7,6 +7,8 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { createWorkspaceFileByPath, updateWorkspaceFileContentByPath, @@ -58,27 +60,43 @@ export const createFileServerTool: BaseServerTool + executeCopilotFileUseCase(context, createWorkspaceFileByPath, { + workspaceId, + path: outputPath, + mode: 'create', + content: '', + encoding: 'utf-8', + contentType, + exactName: true, + secretProvenance: emptyProvenance, + }) try { - const result = - mode === 'overwrite' - ? await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { - workspaceId, - path: outputPath, - mode, - content: '', - encoding: 'utf-8', - contentType, - syncLiveDoc: false, - }) - : await executeCopilotFileUseCase(context, createWorkspaceFileByPath, { - workspaceId, - path: outputPath, - mode, - content: '', - encoding: 'utf-8', - contentType, - exactName: true, - }) + let result + if (mode === 'overwrite') { + try { + result = await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { + workspaceId, + path: outputPath, + mode, + content: '', + encoding: 'utf-8', + contentType, + syncLiveDoc: false, + secretProvenance: emptyProvenance, + }) + } catch (overwriteError) { + // Upsert: overwrite of a missing path falls through to create. + if (asOrchestrationError(overwriteError)?.code !== 'not_found') throw overwriteError + result = await createShell() + } + } else { + result = await createShell() + } logger.info('File created via create_empty_file', { fileId: result.id, diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/copilot/tools/server/image/generate-image.ts index 52cbc5c5dbe..f9f5be655e3 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/copilot/tools/server/image/generate-image.ts @@ -18,6 +18,7 @@ import { import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' @@ -188,6 +189,16 @@ export const generateImageServerTool: BaseServerTool { vfsPath: 'files/Reports/2026/summary.csv', }) }) + + it('upserts: an overwrite of a missing target falls through to create', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mocks.resolveWorkspaceFileReference.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ + id: 'file-new', + name: 'chart.png', + size: 3, + contentType: 'image/png', + downloadUrl: 'url', + vfsPath: 'files/chart.png', + }) + + const written = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + target: { path: 'files/chart.png', mode: 'overwrite' }, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(mocks.updateWorkspaceFileContentBufferByPath.execute).not.toHaveBeenCalled() + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ path: 'files/chart.png', mode: 'create' }), + }) + ) + expect(written).toMatchObject({ id: 'file-new', mode: 'create' }) + }) + + it('keeps a genuine overwrite on the update path', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ id: 'file-1', name: 'chart.png' }) + mocks.updateWorkspaceFileContentBufferByPath.execute.mockResolvedValue({ + id: 'file-1', + name: 'chart.png', + size: 3, + contentType: 'image/png', + downloadUrl: 'url', + vfsPath: 'files/chart.png', + }) + + const written = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal: { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' }, + target: { path: 'files/chart.png', mode: 'overwrite' }, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(mocks.createWorkspaceFileBufferByPath.execute).not.toHaveBeenCalled() + expect(written).toMatchObject({ id: 'file-1', mode: 'overwrite' }) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 6db6bc0319c..58d8c527aa8 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -4,6 +4,7 @@ import { resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, @@ -152,34 +153,48 @@ export async function writeWorkspaceFileByPath(args: { /** Private provenance for the exact bytes being written. */ secretProvenance?: WorkspaceFileSecretProvenance }): Promise { - await assertWorkspaceFileWriteAccess(args) - const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { - const updated = await updateWorkspaceFileContentBufferByPath.execute({ - principal: args.principal, - input: { - workspaceId: args.workspaceId, - path: args.target.path, - mode: 'overwrite', - content: args.buffer, - contentType, - syncLiveDoc: args.syncLiveDoc, - secretProvenance: args.secretProvenance, - }, - }) + // Overwrite is an upsert: "put these bytes at this path". A missing target + // falls through to create instead of failing — otherwise every generator + // (generate_image, ffmpeg, downloads) forces the model through a + // create-vs-overwrite guessing dance racing its own earlier writes. + let missingTarget = false + try { + await assertWorkspaceFileWriteAccess(args) + } catch (accessError) { + if (asOrchestrationError(accessError)?.code !== 'not_found') throw accessError + missingTarget = true + } + if (!missingTarget) { + const updated = await updateWorkspaceFileContentBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'overwrite', + content: args.buffer, + contentType, + syncLiveDoc: args.syncLiveDoc, + secretProvenance: args.secretProvenance, + }, + }) - return { - id: updated.id, - name: updated.name, - size: updated.size, - contentType: updated.contentType, - downloadUrl: updated.downloadUrl, - vfsPath: updated.vfsPath, - mode: 'overwrite', + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.contentType, + downloadUrl: updated.downloadUrl, + vfsPath: updated.vfsPath, + mode: 'overwrite', + } } + args = { ...args, target: { ...args.target, mode: 'create' } } } + await assertWorkspaceFileWriteAccess(args) + const created = await createWorkspaceFileBufferByPath.execute({ principal: args.principal, input: { From 986829e776b66298c7e58a19d8e5b04ee6badd43 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 15:15:52 -0700 Subject: [PATCH 039/135] Route in-band execution through the comprehensive tool dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal execute route used the bare server-tool router, which rejects VFS tools with 'Unknown server tool: read/glob/grep' — so nearly every background agent's first discovery call failed (102 in-band calls in one run, dozens rejected). It now uses the relay's executeTool dispatcher: registered handlers (VFS, function execute) with permission checks and param normalization, falling back to the app tool router — the same surface foreground execution gets. --- apps/sim/app/api/copilot/tools/execute/route.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index a2e4c3b73c6..9da0848f6ca 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -14,7 +14,8 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' import type { ToolCallResult } from '@/lib/copilot/request/types' -import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor' +import { executeTool } from '@/lib/copilot/tool-executor/executor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -128,8 +129,12 @@ export const POST = withRouteHandler((request: NextRequest) => } try { - const handler = createServerToolHandler(toolName) - const result = await handler(params, { + // The relay's comprehensive dispatcher: registry handlers (VFS + // glob/read/grep, function execute, ...) plus the server tool router + // fallback — the plain server-tool adapter alone rejects VFS tools + // with "Unknown server tool". + ensureHandlersRegistered() + const result = await executeTool(toolName, params, { userId, workflowId: workflowId ?? '', workspaceId, From 9c364ad9c6747be1305b48a8d818150351a707e8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 18:22:48 -0700 Subject: [PATCH 040/135] Harden chat stream transition handling --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 5 +++-- .../[workspaceId]/home/hooks/use-chat.test.ts | 14 ++++++++++++-- .../workspace/[workspaceId]/home/hooks/use-chat.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index eaefecea249..4077c2a8c14 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -218,8 +218,9 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) activeResourceParamRef.current = activeResourceParam function handleResourceEvent(resourceId: string, options?: ResourceEventOptions) { - // Agent work makes the resource surface available without replacing an - // existing selection. Explicit user navigation can request activation. + // Agent work surfaces the resource and switches to it as it is created or + // edited; only the browser session stays in the background behind an + // existing selection (see shouldActivateResourceEvent). if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) const activeResourceId = activeResourceParamRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index ec0fe69942d..6caa5a16375 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -33,17 +33,27 @@ vi.mock('next/navigation', () => ({ })) describe('shouldActivateResourceEvent', () => { - it('keeps background agent activity from replacing another selected resource', () => { + it('keeps background browser activity from replacing another selected resource', () => { expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) }) - it('allows an explicit user action to replace another selected resource', () => { + it('allows an explicit user action to surface the browser over another selection', () => { expect( shouldActivateResourceEvent('file-1', 'browser-session', { activate: true, }) ).toBe(true) }) + + it('activates the browser when nothing else is selected', () => { + expect(shouldActivateResourceEvent(null, 'browser-session')).toBe(true) + expect(shouldActivateResourceEvent('browser-session', 'browser-session')).toBe(true) + }) + + it('activates a non-browser resource even when another resource is selected', () => { + expect(shouldActivateResourceEvent('file-1', 'workflow-1')).toBe(true) + expect(shouldActivateResourceEvent('browser-session', 'terminal-session')).toBe(true) + }) }) describe('shouldQueueOutgoingMessage', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2f431e3c354..76ef88190cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1195,12 +1195,20 @@ export interface ResourceEventOptions { export type ResourceEventHandler = (resourceId: string, options?: ResourceEventOptions) => void +/** + * Whether a streamed resource event should activate its tab. Resources switch + * into view as the agent creates or edits them; only the background browser + * session declines to replace an existing selection (it gets an attention + * marker instead), unless the event explicitly requests activation. + */ export function shouldActivateResourceEvent( activeResourceId: string | null, resourceId: string, options?: ResourceEventOptions ): boolean { - return options?.activate === true || !activeResourceId || activeResourceId === resourceId + if (options?.activate === true) return true + if (resourceId !== BROWSER_SESSION_RESOURCE_ID) return true + return !activeResourceId || activeResourceId === resourceId } /** From 503b96faf5c6703b8237c6a8ce5f3b687b8fd7cb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 19:30:43 -0700 Subject: [PATCH 041/135] Harden VFS provenance and resource writes --- apps/sim/connectors/github/github.ts | 10 ++- apps/sim/connectors/slack/slack.ts | 10 ++- .../lib/copilot/generated/tool-catalog-v1.ts | 57 ++++++++++++-- .../lib/copilot/generated/tool-schemas-v1.ts | 53 ++++++++++++- .../lib/copilot/tools/handlers/vfs.test.ts | 73 ++++++++++++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 78 +++++++++++++------ .../server/knowledge/knowledge-base.test.ts | 70 +++++++++++++++++ .../tools/server/knowledge/knowledge-base.ts | 45 ++++++++++- .../copilot/tools/server/table/user-table.ts | 1 + apps/sim/lib/copilot/vfs/file-reader.ts | 10 ++- apps/sim/lib/copilot/vfs/operations.test.ts | 34 ++++++-- apps/sim/lib/copilot/vfs/operations.ts | 7 +- apps/sim/lib/copilot/vfs/serializers.test.ts | 57 ++++++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 10 ++- .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 52 +++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 23 +++++- apps/sim/lib/knowledge/service.ts | 5 +- .../workspace-file-secret-provenance.ts | 6 +- 18 files changed, 545 insertions(+), 56 deletions(-) diff --git a/apps/sim/connectors/github/github.ts b/apps/sim/connectors/github/github.ts index 2509ecb0df4..fc0b0dd1788 100644 --- a/apps/sim/connectors/github/github.ts +++ b/apps/sim/connectors/github/github.ts @@ -418,7 +418,15 @@ export const githubConnector: ConnectorConfig = { } if (!response.ok) { - return { valid: false, error: `Cannot access repository: ${response.status}` } + return { + valid: false, + error: + response.status === 401 + ? 'Cannot access repository: 401 — the token was rejected (invalid, expired, or not a real token). Pass a valid PAT or a {{ENV_VAR}} reference to one.' + : response.status === 403 + ? 'Cannot access repository: 403 — the token lacks access to this repository (missing repo scope, or fine-grained token not granted to it).' + : `Cannot access repository: ${response.status}`, + } } return { valid: true } diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index e82e9c259a4..8e085b4c2fd 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -640,7 +640,10 @@ export const slackConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) } catch { - return { valid: false, error: `Channel not found: ${input}` } + return { + valid: false, + error: `Channel not found: ${input}. The selected credential cannot see it — it may belong to a different Slack workspace, or the channel is private and the connected user/bot is not a member.`, + } } } else { nameLookups.push(trimmed) @@ -684,7 +687,10 @@ export const slackConnector: ConnectorConfig = { } while (cursor) const missing = Array.from(remaining) - return { valid: false, error: `Channel(s) not found: ${missing.join(', ')}` } + return { + valid: false, + error: `Channel(s) not found: ${missing.join(', ')}. The selected credential cannot see them — they may belong to a different Slack workspace, or they are private channels the connected user/bot is not a member of.`, + } } catch (error) { const message = toError(error).message || 'Failed to validate configuration' return { valid: false, error: message } diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index fe635a1bc9a..d101bd9d1e7 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2312,8 +2312,19 @@ export const Extensions: ToolCatalogEntry = { parameters: { properties: { request: { description: 'What tool/skill/MCP action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, subagentId: 'agent', @@ -2500,7 +2511,19 @@ export const File: ToolCatalogEntry = { "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, + required: ['title'], type: 'object', }, subagentId: 'file', @@ -3198,8 +3221,19 @@ export const Knowledge: ToolCatalogEntry = { parameters: { properties: { request: { description: 'What knowledge base action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, subagentId: 'knowledge', @@ -3435,7 +3469,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { apiKey: { type: 'string', description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.', }, chunkingConfig: { type: 'object', @@ -5376,8 +5410,21 @@ export const Table: ToolCatalogEntry = { route: 'subagent', mode: 'async', parameters: { - properties: { request: { description: 'What table action is needed.', type: 'string' } }, - required: ['request'], + properties: { + request: { description: 'What table action is needed.', type: 'string' }, + sessionId: { + description: + 'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, + }, + required: ['request', 'title'], type: 'object', }, subagentId: 'table', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index d94d43860ee..707ca08bcb4 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2267,8 +2267,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What tool/skill/MCP action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, @@ -2463,7 +2474,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.", type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, + required: ['title'], type: 'object', }, resultSchema: undefined, @@ -3135,8 +3158,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What knowledge base action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, @@ -3358,7 +3392,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { apiKey: { type: 'string', description: - 'API key for API-key-based connectors (required when connector auth mode is apiKey)', + 'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.', }, chunkingConfig: { type: 'object', @@ -5281,8 +5315,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'What table action is needed.', type: 'string', }, + sessionId: { + description: + 'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.', + type: 'string', + }, + title: { + description: + "Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.", + maxLength: 120, + type: 'string', + }, }, - required: ['request'], + required: ['request', 'title'], type: 'object', }, resultSchema: undefined, diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 5f89535f76a..da3c15bedc4 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -103,7 +103,7 @@ describe('vfs handlers oversize policy', () => { expect(result.error).toContain('context window') }) - it('fails oversized read results from VFS with grep guidance', async () => { + it('fails oversized read results from VFS with paging guidance', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue(null) vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 }) @@ -112,11 +112,58 @@ describe('vfs handlers oversize policy', () => { const result = await executeVfsRead({ path: 'workflows/My Workflow/state.json' }, GREP_CTX) expect(result.success).toBe(false) - expect(result.error).toContain('Use grep') - expect(result.error).toContain('offset/limit') + expect(result.error).toContain('Page it') + expect(result.error).toContain('grep') expect(result.error).toContain('context window') }) + it('pages an oversized workspace file when offset/limit are passed', async () => { + const vfs = makeVfs() + const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${'y'.repeat(50)}`) + vfs.readFileContent.mockResolvedValue({ + content: lines.join('\n'), + totalLines: lines.length, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const whole = await executeVfsRead({ path: 'files/big.log/content' }, GREP_CTX) + expect(whole.success).toBe(false) + expect(whole.error).toContain('Page it') + + const paged = await executeVfsRead( + { path: 'files/big.log/content', offset: 10, limit: 5 }, + GREP_CTX + ) + expect(paged.success).toBe(true) + expect((paged.output as { content: string }).content).toBe(lines.slice(10, 15).join('\n')) + }) + + it('tells the model to reduce limit when the requested window is still oversized', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: Array.from({ length: 100 }, () => OVERSIZED_INLINE_CONTENT).join('\n'), + totalLines: 100, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/big.log/content', offset: 0, limit: 50 }, + GREP_CTX + ) + expect(result.success).toBe(false) + expect(result.error).toContain('Reduce limit') + }) + + it('notes an empty file instead of returning bare empty content', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ content: '', totalLines: 0 }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead({ path: 'files/hi.txt/content' }, GREP_CTX) + expect(result.success).toBe(true) + expect((result.output as { note?: string }).note).toContain('empty') + }) + it('fails file-backed oversized read placeholders with original message', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue( @@ -723,6 +770,26 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => { expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json') }) + it('explains an empty uploads glob instead of returning a bare []', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + listChatUploads.mockResolvedValue([]) + + const result = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT) + expect(result.success).toBe(true) + expect((result.output as { files: string[]; note?: string }).files).toEqual([]) + expect((result.output as { note?: string }).note).toContain('no uploads') + }) + + it('explains an empty user-local glob instead of returning a bare []', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGlob({ pattern: 'user-local/**' }, GREP_CTX_CHAT) + expect(result.success).toBe(true) + expect((result.output as { note?: string }).note).toContain('user-local') + }) + it('reads an upload directly, tolerating a spurious /content suffix', async () => { const vfs = makeVfs() getOrMaterializeVFS.mockResolvedValue(vfs) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index c8f6d2c642d..c3485b72598 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -267,6 +267,25 @@ export async function executeVfsGlob( } logger.debug('vfs_glob result', { pattern, fileCount: files.length }) + // A bare [] on a namespace that is legitimately absent reads as "my glob is + // wrong". Say why it's empty so the model doesn't retry pattern variants. + if (files.length === 0) { + if (pattern.startsWith('uploads')) { + return { + success: true, + output: { files, note: 'This chat has no uploads.' }, + } + } + if (pattern.startsWith('user-local')) { + return { + success: true, + output: { + files, + note: 'No user-local folder is granted in this chat, so user-local/ is empty.', + }, + } + } + } return { success: true, output: { files } } } catch (err) { logger.error('vfs_glob failed', { @@ -336,28 +355,29 @@ export async function executeVfsRead( const uploadResult = uploadEnvelope?.value if (uploadResult) { const isAttachment = hasModelAttachment(uploadResult) - if ( - !isAttachment && - (isOversizedReadPlaceholder(uploadResult) || - serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS) - ) { + if (!isAttachment && isOversizedReadPlaceholder(uploadResult)) { + // The loader refused to materialize the bytes at all; a window can't help. + return { success: false, error: uploadResult.content } + } + // Window BEFORE the inline-size gate, so offset/limit genuinely page a + // large upload instead of the gate rejecting the whole file first. + const windowedUpload = applyWindow(uploadResult) + if (!isAttachment && serializedResultSize(windowedUpload) > TOOL_RESULT_MAX_INLINE_CHARS) { logger.warn('Upload read result too large', { path, hasAttachment: isAttachment, contentLength: uploadResult.content.length, - serializedSize: serializedResultSize(uploadResult), + serializedSize: serializedResultSize(windowedUpload), + windowed: offset !== undefined || limit !== undefined, }) return { success: false, - error: isOversizedReadPlaceholder(uploadResult) - ? uploadResult.content - : // Same as the workspace-file branch below: this size gate runs on - // the whole upload before any window, so "retry with offset/limit" - // would loop. Point at grep scoped to this path instead. - `Read result too large to return inline. Grep this single upload instead of reading it — grep({pattern: "...", path: "${path}"}) — because offset/limit do NOT shrink an upload read: the size check runs on the whole file before the window is applied.`, + error: + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}).`, } } - const windowedUpload = applyWindow(uploadResult) const provenanceView = offset === undefined && limit === undefined ? (uploadEnvelope?.view ?? 'derived') @@ -406,25 +426,33 @@ export async function executeVfsRead( const fileContent = fileEnvelope?.value if (fileContent) { const isAttachment = hasModelAttachment(fileContent) + if (!isAttachment && isOversizedReadPlaceholder(fileContent)) { + // The loader refused to materialize the bytes at all; a window can't help. + return { success: false, error: fileContent.content } + } + // Window BEFORE the inline-size gate, so offset/limit genuinely page a + // large file instead of the gate rejecting the whole file first — the + // paging advice in the error below has to actually work. + const windowedFileContent = applyWindow(fileContent) if ( !isAttachment && - (isOversizedReadPlaceholder(fileContent) || - serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS) + serializedResultSize(windowedFileContent) > TOOL_RESULT_MAX_INLINE_CHARS ) { logger.warn('File read result too large', { path, hasAttachment: isAttachment, contentLength: fileContent.content.length, - serializedSize: serializedResultSize(fileContent), + serializedSize: serializedResultSize(windowedFileContent), + windowed: offset !== undefined || limit !== undefined, }) return { success: false, - error: isOversizedReadPlaceholder(fileContent) - ? fileContent.content - : `Read result too large to return inline. Locate the relevant section first — grep({pattern: \"...\", path: \"${path}\"}) — then page it with read({path: \"${path}\", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, + error: + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}), then read({path: "${path}", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, } } - const windowedFileContent = applyWindow(fileContent) const provenanceView = offset === undefined && limit === undefined ? (fileEnvelope?.view ?? 'derived') : 'derived' if ( @@ -454,7 +482,11 @@ export async function executeVfsRead( }) return { success: true, - output: windowedFileContent, + output: + fileContent.content === '' && !isAttachment + ? // An empty string with no explanation reads as a failed read. + { ...windowedFileContent, note: 'File is empty (0 bytes).' } + : windowedFileContent, } } @@ -491,7 +523,9 @@ export async function executeVfsRead( return { success: false, error: - 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', + offset !== undefined || limit !== undefined + ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` + : 'Read result too large to return inline. Page it with read({path, offset, limit}), or use grep with a more specific pattern to locate the relevant section first. Avoid catch-all greps or full-file reads because they waste context window.', } } logger.debug('vfs_read result', { path, totalLines: result.totalLines, offset, limit }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 640032aa9c7..95afa3a9066 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -83,6 +83,12 @@ const { vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ ManageKnowledgeBase: { id: 'manage_knowledge_base' }, })) +const { mockGetEffectiveDecryptedEnv } = vi.hoisted(() => ({ + mockGetEffectiveDecryptedEnv: vi.fn(), +})) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv, +})) vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { knowledgeBaseCreated: mockKnowledgeBaseCreated, @@ -700,6 +706,70 @@ describe('manage_knowledge_base trusted application delegation', () => { }) }) + it.each(['{{SIM_GITHUB_PAT}}', '$SIM_GITHUB_PAT', 'SIM_GITHUB_PAT'])( + 'resolves the %s environment reference into the connector API key', + async (ref) => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { knowledgeBaseId: KNOWLEDGE_BASE.id, connectorType: 'github', apiKey: ref }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(true) + const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { + input: { apiKey?: string } + } + expect(call.input.apiKey).toBe('ghp_realtoken') + } + ) + + it('names the missing variable instead of sending a placeholder upstream', async () => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({}) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'github', + apiKey: '{{SIM_GITHUB_PAT}}', + }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('SIM_GITHUB_PAT') + expect(result.message).toContain('not set') + expect(mockCreateKnowledgeConnector).not.toHaveBeenCalled() + }) + + it('passes a raw API key through untouched', async () => { + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' }) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'add_connector', + args: { + knowledgeBaseId: KNOWLEDGE_BASE.id, + connectorType: 'github', + apiKey: 'ghp_literal_key', + }, + }, + BILLED_CONTEXT + ) + + expect(result.success).toBe(true) + const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { + input: { apiKey?: string } + } + expect(call.input.apiKey).toBe('ghp_literal_key') + }) + it('preserves caller-actionable tag provenance conflicts', async () => { mockDeleteKnowledgeTag.mockRejectedValueOnce( new OrchestrationError( diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index cd9f300a2ad..ffdf834201d 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -19,6 +19,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' @@ -53,6 +54,42 @@ import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-sec const logger = createLogger('KnowledgeBaseServerTool') +/** + * Resolves an environment-variable reference passed as a connector API key. + * + * Models reference workspace secrets the way workflows do — `{{SIM_GITHUB_PAT}}` + * (and, when improvising, `$SIM_GITHUB_PAT` or the bare name). Before this, + * the literal placeholder string was sent upstream as the bearer token and the + * provider answered 401 — an error that never named the real problem. A raw + * key that matches no reference form passes through untouched. + * + * Returns an error string when a reference names a variable that is not set, + * so the model learns the actual fix instead of retrying reference syntaxes. + */ +async function resolveConnectorApiKey( + context: ServerToolContext, + workspaceId: string, + apiKey: string | undefined +): Promise<{ apiKey?: string; error?: string }> { + if (!apiKey) return { apiKey } + const braced = apiKey.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) + const dollar = apiKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) + const referencedName = braced?.[1] ?? dollar?.[1] + const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) + const name = referencedName ?? (Object.hasOwn(env, apiKey) ? apiKey : undefined) + if (!name) return { apiKey } + const value = env[name] + if (value === undefined || value === '') { + return { + error: `Environment variable "${name}" is not set for this workspace or user, so it cannot be used as the connector API key. Set it first, pass a different {{ENV_VAR}} reference, or pass the raw key.`, + } + } + // Activate the resolved secret on the call's egress registry so any + // accidental echo of it (provider error bodies, logs) is redacted. + context.resolvedSecretTraceRegistry?.recordResolved(name, value) + return { apiKey: value } +} + function requireKnowledgeBillingAttribution( context: ServerToolContext, workspaceId: string @@ -291,6 +328,7 @@ export const knowledgeBaseServerTool: BaseServerTool diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 8ced02221e3..437970ea10e 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -194,6 +194,7 @@ export const userTableServerTool: BaseServerTool name: args.name, description: args.description, schema: normalizeSchemaSelectColumns(args.schema as TableSchema), + folderPath: args.folderPath, workspaceId, }) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 3940152b024..75bb5c5bdf0 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -40,8 +40,14 @@ function recordSpanError(span: Span, err: unknown) { const logger = createLogger('FileReader') -/** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ -export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB +/** + * Text-read materialization cap — exported so callers can align their own byte-sniff budgets + * with what read() can actually load. This bounds what the server LOADS, not what the model + * receives inline: the read handler windows (offset/limit) and inline-size-gates the result, + * so a large file is paged rather than sent whole. 20MB keeps multi-MB logs/exports greppable + * and pageable while still refusing genuinely unbounded blobs. + */ +export const MAX_TEXT_READ_BYTES = 20 * 1024 * 1024 // 20 MB /** Vision-attachment cap: what the prepared image must fit into after resizing. */ export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB // Parseable-document byte cap. Large office/PDF files can still diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index b1d308f7250..1f238d010e8 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -54,14 +54,36 @@ describe('glob', () => { expect(hits).toContain('files/a/meta.json') }) - it('treats braces literally when nobrace is set (matches old builder)', () => { + it('expands brace alternatives across path segments', () => { const files = vfsFromEntries([ - ['weird{brace}/x', ''], - ['weirdA/x', ''], + ['workflows/Elder/state.json', '{}'], + ['workflows/Utils/state.json', '{}'], + ['workflows/Other/state.json', '{}'], + ]) + const hits = glob(files, 'workflows/{Elder,Utils}/state.json') + expect(hits.sort()).toEqual(['workflows/Elder/state.json', 'workflows/Utils/state.json']) + }) + + it('expands extension braces', () => { + const files = vfsFromEntries([ + ['files/a.png', ''], + ['files/b.md', ''], + ['files/c.txt', ''], + ]) + const hits = glob(files, 'files/*.{png,md}') + expect(hits.sort()).toEqual(['files/a.png', 'files/b.md']) + }) + + it('expands braces in decoded-form patterns against encoded keys', () => { + const files = vfsFromEntries([ + ['workflows/Elder%20v1/state.json', '{}'], + ['workflows/Elder%20v2/state.json', '{}'], + ]) + const hits = glob(files, 'workflows/{Elder v1,Elder v2}/state.json') + expect(hits.sort()).toEqual([ + 'workflows/Elder%20v1/state.json', + 'workflows/Elder%20v2/state.json', ]) - const hits = glob(files, 'weird{brace}/*') - expect(hits).toContain('weird{brace}/x') - expect(hits).not.toContain('weirdA/x') }) }) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index 325609f63f4..b22d28d6490 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -108,8 +108,10 @@ export interface ReadResult { /** * Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*` - * never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace` - * and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for + * never crosses path slashes (required for `files` + star + `meta.json` style paths). Brace + * expansion is ON — `workflows/{A,B}/**` and `*.{png,md}` are the natural way to batch a + * glob, and with `nobrace` they silently matched nothing, which reads as "no such files". + * `noext` still disables extglob expansion like the old builder. Uses `micromatch` for * well-tested `**` and edge cases instead of a custom `RegExp`. */ /** @@ -130,7 +132,6 @@ const VFS_GLOB_OPTIONS: micromatch.Options = { bash: false, dot: false, windows: false, - nobrace: true, noext: true, } diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 5e86b445895..7c36001d71c 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -13,6 +13,7 @@ import type { ToolConfig } from '@/tools/types' import { serializeApiKeyIntegrations, serializeBlockSchema, + serializeConnectors, serializeCredentials, serializeDeployments, serializeFileMeta, @@ -528,3 +529,59 @@ describe('serializeCredentials — type distinguishes reconnect flow', () => { expect(json[0].type).toBeUndefined() }) }) + +describe('serializeConnectors — cloneable references, never key material', () => { + const now = new Date('2026-08-14T00:00:00.000Z') + + it('exposes credentialId and sourceConfig so a connector can be recreated', () => { + const json = JSON.parse( + serializeConnectors([ + { + id: 'conn-1', + connectorType: 'slack', + status: 'active', + syncMode: 'incremental', + syncIntervalMinutes: 1440, + credentialId: 'cred-42', + sourceConfig: { channel: 'eng-help', maxMessages: '500' }, + lastSyncAt: now, + lastSyncError: null, + lastSyncDocCount: 12, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt: now, + }, + ]) + ) + expect(json[0]).toMatchObject({ + id: 'conn-1', + credentialId: 'cred-42', + sourceConfig: { channel: 'eng-help', maxMessages: '500' }, + }) + expect(JSON.stringify(json)).not.toContain('encryptedApiKey') + }) + + it('omits the credential reference when a connector has none (API-key connectors)', () => { + const json = JSON.parse( + serializeConnectors([ + { + id: 'conn-2', + connectorType: 'github', + status: 'active', + syncMode: 'incremental', + syncIntervalMinutes: 1440, + credentialId: null, + sourceConfig: { repository: 'simstudioai/sim', branch: 'staging' }, + lastSyncAt: null, + lastSyncError: null, + lastSyncDocCount: null, + nextSyncAt: null, + consecutiveFailures: 0, + createdAt: now, + }, + ]) + ) + expect(json[0].credentialId).toBeUndefined() + expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' }) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 5ad229b53dd..dddf4d6a61e 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -313,7 +313,11 @@ export function serializeDocuments( /** * Serialize KB connectors for VFS knowledgebases/{name}/connectors.json. - * Shows connector type, sync status, and schedule — NOT credentials or source config. + * Shows connector type, sync status, schedule, the credential REFERENCE + * (an opaque id — never key material; API keys stay encrypted and are never + * serialized), and the source config (repo/branch/channels). The last two are + * what make a connector cloneable: without them, recreating a working + * connector on a new KB meant guessing both the credential and the channels. */ export function serializeConnectors( connectors: Array<{ @@ -322,6 +326,8 @@ export function serializeConnectors( status: string syncMode: string syncIntervalMinutes: number + credentialId?: string | null + sourceConfig?: unknown lastSyncAt: Date | null lastSyncError: string | null lastSyncDocCount: number | null @@ -337,6 +343,8 @@ export function serializeConnectors( status: c.status, syncMode: c.syncMode, syncIntervalMinutes: c.syncIntervalMinutes, + credentialId: c.credentialId ?? undefined, + sourceConfig: c.sourceConfig ?? undefined, lastSyncAt: c.lastSyncAt?.toISOString(), lastSyncError: c.lastSyncError || undefined, lastSyncDocCount: c.lastSyncDocCount ?? undefined, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts index d267b508656..7e0a1080076 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts @@ -34,6 +34,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ( })) import { WorkspaceVFS } from '@/lib/copilot/vfs/workspace-vfs' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024 const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1024 * 1024 @@ -164,6 +165,57 @@ describe('WorkspaceVFS lazy grep resilience', () => { }) }) +describe('WorkspaceVFS oversized content reads', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function arrangeOversizedContentRead() { + const record = { + id: 'file-big', + workspaceId: 'ws-1', + name: 'big.tsv', + key: 'big.tsv', + path: '/api/files/serve/big.tsv', + size: 7_500_000, + type: 'text/tab-separated-values', + uploadedBy: 'user-1', + deletedAt: null, + uploadedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + storageContext: 'workspace' as const, + } + listAllWorkspaceFilesExecute.mockResolvedValue({ files: [record] }) + findWorkspaceFileRecord.mockReturnValue(record) + readWorkspaceFileContentExecute.mockRejectedValue( + new PayloadSizeLimitError({ label: 'Workspace file', maxBytes: 20_971_520 }) + ) + + const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + Object.assign(vfs, { _workspaceId: 'ws-1' }) + const internals = vfs as unknown as { files: Map } + internals.files.set('files/big.tsv', '') + return vfs + } + + it('answers a cap breach with an oversized placeholder, not "not found"', async () => { + const vfs = arrangeOversizedContentRead() + + const result = await vfs.readFileContent('files/big.tsv/content') + + expect(result).not.toBeNull() + expect(result).toMatchObject({ placeholder: 'oversized' }) + expect(result?.content).toContain('File too large') + expect(result?.content).toContain('big.tsv') + }) + + it('reports a cap breach honestly for grep instead of "content not found"', async () => { + const vfs = arrangeOversizedContentRead() + + await expect(vfs.grepFile('files/big.tsv', 'needle')).rejects.toThrow(/too large to search/) + }) +}) + describe('WorkspaceVFS decoded-equivalent resolution', () => { it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => { const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index fc13422e547..924f3c01f13 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -100,6 +100,7 @@ import { isDocSandboxEnabled, isHosted, } from '@/lib/core/config/env-flags' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -1061,6 +1062,9 @@ export class WorkspaceVFS { if (!result) { throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`) } + if (result.value.placeholder === 'oversized') { + throw new ops.WorkspaceFileGrepError(`File is too large to search: ${result.value.content}`) + } return { value: ops.grepReadResult(leaf, result.value, pattern, contentPath, options), @@ -1600,6 +1604,8 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' + let sizeCappedRecord: WorkspaceFileRecord | undefined + let sizeCap = MAX_TEXT_READ_BYTES try { const { files } = await listAllWorkspaceFiles.execute({ principal: this.requireFilePrincipal(), @@ -1607,15 +1613,17 @@ export class WorkspaceVFS { }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null + sizeCappedRecord = record + sizeCap = isImageFileType(resolveEffectiveMimeType(record.type, record.name)) + ? MAX_IMAGE_SOURCE_BYTES + : MAX_TEXT_READ_BYTES const { file, content } = await readWorkspaceFileContent.execute({ principal: this.requireFilePrincipal(), input: { fileId: record.id, assertedWorkspaceId: this._workspaceId, includeDeleted: scope === 'archived', - maxBytes: isImageFileType(resolveEffectiveMimeType(record.type, record.name)) - ? MAX_IMAGE_SOURCE_BYTES - : MAX_TEXT_READ_BYTES, + maxBytes: sizeCap, }, }) const result = await readFileRecord(file, content) @@ -1627,6 +1635,15 @@ export class WorkspaceVFS { ) : null } catch (err) { + // A cap breach is an answer, not a lookup failure: returning null here + // reported multi-MB files as "content not found". The oversized + // placeholder tells the model the file exists and why it can't be read. + if (isPayloadSizeLimitError(err) && sizeCappedRecord) { + return bindWorkspaceFileResult( + sizeCappedRecord, + readPlaceholder.fileTooLarge(sizeCappedRecord.name, sizeCappedRecord.size ?? 0, sizeCap) + ) + } logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, path, diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index d3b9ffa5e37..e24033eb6f6 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -55,7 +55,10 @@ const logger = createLogger('KnowledgeBaseService') */ export class KnowledgeBaseConflictError extends OrchestrationError { constructor(name: string) { - super('conflict', `A knowledge base named "${name}" already exists in this workspace`) + super( + 'conflict', + `A knowledge base named "${name}" already exists in this workspace. Names are unique across the whole workspace — folders do not namespace them — so pick a different name, or rename/delete the existing knowledge base first.` + ) this.name = 'KnowledgeBaseConflictError' } } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts index dd13b94524e..d7637b486f5 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts @@ -610,8 +610,10 @@ export async function getBoundWorkspaceFileSecretProvenance( eq(workspaceFiles.id, identity.fileId), eq(workspaceFiles.key, identity.key), eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, identity.context), - isNull(workspaceFiles.deletedAt) + eq(workspaceFiles.context, identity.context) + // Deliberately no deletedAt filter: `id` alone pins the exact row, and + // recently-deleted/ reads are a real surface — excluding soft-deleted + // rows made every archived file read as provenance-unknown and refused. ) ) .limit(1) From 56128bec9be08c8499984df60386e70415af9c2f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 14 Aug 2026 20:16:52 -0700 Subject: [PATCH 042/135] Standardize tool environment references --- apps/desktop/src/main/terminal/session.ts | 2 +- .../api/copilot/tools/execute/route.test.ts | 12 ++- .../message-content/message-content.test.ts | 4 +- apps/sim/connectors/airtable/airtable.ts | 10 ++- apps/sim/connectors/confluence/confluence.ts | 7 +- apps/sim/connectors/discord/discord.ts | 11 ++- apps/sim/connectors/gitlab/gitlab.ts | 14 +++- .../connectors/google-drive/google-drive.ts | 7 +- .../microsoft-teams/microsoft-teams.ts | 5 +- apps/sim/connectors/notion/notion.ts | 7 +- .../lib/copilot/generated/tool-catalog-v1.ts | 20 +++-- .../lib/copilot/generated/tool-schemas-v1.ts | 20 +++-- .../tools/handlers/deployment/custom-block.ts | 6 +- .../tools/handlers/deployment/deploy.ts | 16 +++- .../tools/handlers/function-execute.ts | 10 +++ .../handlers/management/manage-custom-tool.ts | 2 +- .../handlers/management/manage-mcp-tool.ts | 2 +- .../handlers/management/manage-sandbox.ts | 6 +- .../lib/copilot/tools/handlers/resources.ts | 16 +++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 18 +++-- .../lib/copilot/tools/server/env-reference.ts | 39 ++++++++++ .../files/download-to-workspace-file.ts | 10 ++- .../tools/server/files/edit-content.ts | 15 +++- .../copilot/tools/server/files/share-file.ts | 16 +++- .../tools/server/files/workspace-file.ts | 5 +- .../tools/server/knowledge/knowledge-base.ts | 3 +- .../lib/copilot/tools/server/media/ffmpeg.ts | 5 +- .../tools/server/other/search-online.ts | 4 +- .../copilot/tools/server/table/table-views.ts | 20 ++--- .../workflow/edit-workflow/validation.ts | 4 +- .../lib/copilot/tools/tool-display.test.ts | 18 ++++- apps/sim/lib/copilot/tools/tool-display.ts | 34 ++++++++- apps/sim/lib/copilot/vfs/serializers.ts | 17 +++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 6 ++ .../knowledge/application/knowledge-bases.ts | 2 + .../lib/knowledge/orchestration/connectors.ts | 6 +- .../sim/lib/table/application/context.test.ts | 5 +- apps/sim/lib/table/application/context.ts | 7 +- apps/sim/lib/table/application/tables.ts | 11 ++- apps/sim/lib/table/application/views.ts | 30 ++++++-- .../workflows/orchestration/chat-deploy.ts | 76 +++++++++++++------ apps/sim/tools/index.ts | 26 ++++++- apps/sim/tools/params.ts | 10 +++ 43 files changed, 464 insertions(+), 100 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/env-reference.ts diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 350c1aa070d..beb64a403a3 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -851,7 +851,7 @@ export class TerminalSession { (pending) => ({ command: pending.command, output: - 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', + 'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. If it is a pager (less, git log, man — the screen ends with ":" or "(END)"), nothing more is coming: exit it by sending terminal_input text "q"; terminal_kill delivers Ctrl-C, which a pager ignores. The user can also drive it in the panel. terminal_run reports BUSY until it exits.', status: 'interactive', exitCode: null, durationMs: Date.now() - pending.startedAt, diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts index e1fc2d39141..87e7d1e88f2 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.test.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -18,8 +18,16 @@ vi.mock('@/lib/copilot/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext, })) -vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({ - createServerToolHandler: () => mockHandler, +vi.mock('@/lib/copilot/tool-executor', () => ({ + ensureHandlersRegistered: vi.fn(), +})) + +vi.mock('@/lib/copilot/tool-executor/executor', () => ({ + executeTool: ( + _toolName: string, + params: Record, + context: Record + ) => mockHandler(params, context), })) vi.mock('@/lib/copilot/request/tools/resources', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index a777c7006a9..19f953fdacc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -591,9 +591,9 @@ describe('completed tool titles', () => { expect(failures).toEqual([]) }) - it('keeps present tense while executing and on error', () => { + it('keeps present tense while executing; failed rows say so', () => { expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') - expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') + expect(firstToolTitle([queryLogsCall('error')])).toBe('Failed querying logs') }) }) diff --git a/apps/sim/connectors/airtable/airtable.ts b/apps/sim/connectors/airtable/airtable.ts index 8f6ee74d63a..5a8fc422d0e 100644 --- a/apps/sim/connectors/airtable/airtable.ts +++ b/apps/sim/connectors/airtable/airtable.ts @@ -212,7 +212,10 @@ export const airtableConnector: ConnectorConfig = { if (response.status === 403) { return { valid: false, error: 'Access denied. Check your Airtable permissions.' } } - return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` } + return { + valid: false, + error: `Airtable API error: ${response.status} — 401 means an invalid PAT (or an unresolved {{ENV_VAR}} placeholder); 403 means the PAT has no access to base "${baseId}". Detail: ${errorText}`, + } } const viewId = sourceConfig.viewId as string | undefined @@ -229,7 +232,10 @@ export const airtableConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!viewResponse.ok) { - return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` } + return { + valid: false, + error: `View "${viewId}" not found in table "${tableIdOrName}" — or the PAT lacks access to it.`, + } } } diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 470cdc8ab68..7f2201381e1 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -493,7 +493,10 @@ export const confluenceConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Failed to validate spaces: ${response.status}` } + return { + valid: false, + error: `Failed to list Confluence spaces: ${response.status} — 401/403 means the credential lacks space-read scope on this site; 404 means the domain is wrong.`, + } } const data = await response.json() const results = (data.results as Array> | undefined) ?? [] @@ -502,7 +505,7 @@ export const confluenceConnector: ConnectorConfig = { if (missing.length > 0) { return { valid: false, - error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')} — the credential may not see them; they may be in another Atlassian site or restricted spaces the connected user is not a member of.`, } } return { valid: true } diff --git a/apps/sim/connectors/discord/discord.ts b/apps/sim/connectors/discord/discord.ts index 412d8d28203..299cb87a213 100644 --- a/apps/sim/connectors/discord/discord.ts +++ b/apps/sim/connectors/discord/discord.ts @@ -272,10 +272,17 @@ export const discordConnector: ConnectorConfig = { } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') if (message.includes('401') || message.includes('403')) { - return { valid: false, error: 'Invalid bot token or missing permissions for this channel' } + return { + valid: false, + error: + 'Discord rejected the request (401/403) — the bot token is invalid, or the bot lacks access to this channel (invite the bot to the server/channel and grant Read Message History).', + } } if (message.includes('404')) { - return { valid: false, error: `Channel not found: ${channelId}` } + return { + valid: false, + error: `Channel not found: ${channelId}. The bot cannot see it — invite the bot to that server/channel, or check the channel id.`, + } } return { valid: false, error: message } } diff --git a/apps/sim/connectors/gitlab/gitlab.ts b/apps/sim/connectors/gitlab/gitlab.ts index 99586321f48..3711a9b834c 100644 --- a/apps/sim/connectors/gitlab/gitlab.ts +++ b/apps/sim/connectors/gitlab/gitlab.ts @@ -968,8 +968,18 @@ export const gitlabConnector: ConnectorConfig = { if (response.status === 404) { return { valid: false, error: `Project "${project}" not found on ${host}` } } - if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid token or insufficient permissions' } + if (response.status === 401) { + return { + valid: false, + error: + 'GitLab rejected the token (401) — it is invalid, expired, or an unresolved {{ENV_VAR}} placeholder. Pass a valid token or a {{ENV_VAR}} reference to one.', + } + } + if (response.status === 403) { + return { + valid: false, + error: `GitLab token lacks access (403) — it needs read_api/read_repository on "${project}".`, + } } if (!response.ok) { return { valid: false, error: `Cannot access project: ${response.status}` } diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index e7c2def3b5c..5c692f52ac4 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -358,7 +358,7 @@ export const googleDriveConnector: ConnectorConfig = { } return { valid: false, - error: `Failed to access folder "${folderId}": ${response.status}`, + error: `Failed to access folder "${folderId}": ${response.status} — 403 means the folder exists but is not shared with the connected Google account.`, } } @@ -383,7 +383,10 @@ export const googleDriveConnector: ConnectorConfig = { ) if (!response.ok) { - return { valid: false, error: `Failed to access Google Drive: ${response.status}` } + return { + valid: false, + error: `Failed to access Google Drive: ${response.status} — 401 means the token expired (reconnect the Google credential); 403 usually means a missing Drive scope.`, + } } } diff --git a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts index 5355918f867..6c4b07a42a5 100644 --- a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts +++ b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts @@ -348,7 +348,10 @@ export const microsoftTeamsConnector: ConnectorConfig = { for (const channelInput of channelInputs) { const channel = await resolveChannel(accessToken, teamId, channelInput) if (!channel) { - return { valid: false, error: `Channel not found: ${channelInput}` } + return { + valid: false, + error: `Channel not found: ${channelInput}. The connected account cannot see it — it may be in a different team/tenant, or a private channel the user is not a member of.`, + } } // Verify we can read messages by fetching a single message diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index 3904d7ddef2..21f6aaf4e91 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -282,7 +282,7 @@ export const notionConnector: ConnectorConfig = { if (!response.ok) { return { valid: false, - error: `Cannot access database ${databaseId}: ${response.status}`, + error: `Cannot access database ${databaseId}: ${response.status} — 401 means a rejected token; 404 usually means the database is not shared with this Notion integration (share it from the page's "Connections" menu).`, } } } @@ -300,7 +300,10 @@ export const notionConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Cannot access page: ${response.status}` } + return { + valid: false, + error: `Cannot access page ${rootPageId}: ${response.status} — the page is likely not shared with this Notion integration; add it under the page's "Connections" menu.`, + } } } else { // Workspace scope — just verify token works diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index d101bd9d1e7..fcfe6f075b3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3482,7 +3482,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { }, minSize: { type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', + description: 'Minimum chunk size (1-2000, default: 100)', default: 1, }, overlap: { @@ -3604,7 +3604,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = { }, topK: { type: 'number', - description: 'Number of results to return (1-50, default: 5)', + description: 'Number of results to return (1-100, default: 5)', default: 5, }, workspaceId: { @@ -3672,7 +3672,8 @@ export const ManageMcpConnection: ToolCatalogEntry = { }, headers: { type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', + description: + 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.', }, name: { type: 'string', description: 'Display name for the MCP server' }, timeout: { @@ -4371,7 +4372,7 @@ export const QueryUserTable: ToolCatalogEntry = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.', }, order: { type: 'array', @@ -4548,7 +4549,16 @@ export const RestoreResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type to restore.', - enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'], + enum: [ + 'workflow', + 'table', + 'file', + 'knowledgebase', + 'folder', + 'file_folder', + 'table_folder', + 'knowledge_folder', + ], }, }, required: ['type', 'id'], diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 707ca08bcb4..b3f2047f631 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3405,7 +3405,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, minSize: { type: 'number', - description: 'Minimum chunk size (1-2000, default: 1)', + description: 'Minimum chunk size (1-2000, default: 100)', default: 1, }, overlap: { @@ -3539,7 +3539,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, topK: { type: 'number', - description: 'Number of results to return (1-50, default: 5)', + description: 'Number of results to return (1-100, default: 5)', default: 5, }, workspaceId: { @@ -3608,7 +3608,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, headers: { type: 'object', - description: 'Optional HTTP headers to send with requests (key-value pairs)', + description: + 'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.', }, name: { type: 'string', @@ -4300,7 +4301,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + 'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.', }, order: { type: 'array', @@ -4497,7 +4498,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type to restore.', - enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'], + enum: [ + 'workflow', + 'table', + 'file', + 'knowledgebase', + 'folder', + 'file_folder', + 'table_folder', + 'knowledge_folder', + ], }, }, required: ['type', 'id'], diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index c76fdf32b78..051a97fbbd4 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -303,6 +303,10 @@ export async function executeDeployCustomBlock( return { success: false, error: error.message } } logger.error('Custom block deployment failed', { error }) - return { success: false, error: 'Custom block deployment failed due to a system error' } + return { + success: false, + error: + 'Publishing the custom block failed inside Sim; assume it was NOT published. Call get_deployment_status to confirm, retry once, and report the failure if it repeats instead of retrying further.', + } } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index d5a0260f2dc..c8cc2fc7d1a 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -4,6 +4,7 @@ import { messageForCopilotWorkflowError, } from '@/lib/copilot/application/execute-workflow-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { @@ -357,6 +358,19 @@ export async function executeDeployChat( } } + // "Use the password in {{CHAT_PW}}" arrives as the literal reference — + // resolve it, or the placeholder string becomes the chat's real password. + const resolvedPassword = await resolveEnvReferenceSecretArg({ + userId: context.userId, + workspaceId: context.workspaceId, + value: params.password ?? undefined, + argName: 'password', + registry: context.resolvedSecretTraceRegistry, + }) + if (resolvedPassword.error) { + return { success: false, error: resolvedPassword.error } + } + const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, { workflowId, assertedWorkspaceId: context.workspaceId, @@ -371,7 +385,7 @@ export async function executeDeployChat( imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl, }, authType: params.authType, - password: params.password, + password: resolvedPassword.value, allowedEmails: params.allowedEmails, outputConfigs: params.outputConfigs, includeThinking: params.includeThinking, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 8923e6a4ab8..ae68024d365 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -636,6 +636,16 @@ export async function executeFunctionExecute( 'internalSandboxProfile', PRIVATE_SECRET_PROVENANCE_FIELD, ]) + // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to + // milliseconds", default 10, cap 300); the underlying function tool takes + // MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort. + // Values ≤ 600 are read as seconds; larger values are assumed to already be + // milliseconds (a model habit worth tolerating). Both clamp to the 300s cap. + if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) { + const raw = enrichedParams.timeout + const ms = raw <= 600 ? raw * 1000 : raw + enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000) + } if (params.sandboxId !== undefined) { if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) { throw new Error('sandboxId must be a non-empty Sim sandbox id') diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts index 7d43faa71b0..adb9e45f3a0 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -250,7 +250,7 @@ export async function executeManageCustomTool( error: classified && classified.code !== 'internal' ? classified.message - : 'Failed to manage custom tool', + : `The ${operation ?? 'custom tool'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, } } } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts index da6dc0cdd43..066fb6b8539 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -205,7 +205,7 @@ export async function executeManageMcpTool( error: classified && classified.code !== 'internal' ? classified.message - : 'Failed to manage MCP server', + : `The ${operation ?? 'MCP server'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, } } } diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts index 90f7517b80b..47913ca6103 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts @@ -102,7 +102,11 @@ export async function executeManageSandbox( workspaceId, SANDBOX_MUTATION_LIMIT ) - if (limited) return { success: false, error: 'Rate limit exceeded' } + if (limited) + return { + success: false, + error: `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.`, + } if (operation === 'add') { const parsed = createSandboxBodySchema.safeParse({ diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 0bbb90efe0d..1dbfbeb00cd 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -66,7 +66,9 @@ async function resolveResource( const wf = await getWorkflowById(item.id) if (!wf) return { error: `No workflow with id "${item.id}".` } if (context.workspaceId && wf.workspaceId !== context.workspaceId) - return { error: `Workflow not found in the current workspace.` } + return { + error: `Workflow "${item.id}" is not in the current workspace — run glob("workflows/*/meta.json") for workflows you can reference.`, + } resourceId = wf.id title = wf.name } @@ -75,7 +77,9 @@ async function resolveResource( const tbl = await getTableById(item.id) if (!tbl) return { error: `No table with id "${item.id}".` } if (context.workspaceId && tbl.workspaceId !== context.workspaceId) - return { error: `Table not found in the current workspace.` } + return { + error: `Table "${item.id}" is not in the current workspace — run glob("tables/*") for tables you can reference.`, + } resourceId = tbl.id title = tbl.name if (item.view) { @@ -113,7 +117,9 @@ async function resolveResource( classified?.code === 'forbidden' || classified?.code === 'unauthorized' ) { - return { error: 'Knowledge base not found in the current workspace.' } + return { + error: `Knowledge base "${item.id}" is not readable in the current workspace — it does not exist here or you lack access. Run glob("knowledgebases/*") for ids you can open.`, + } } throw error } @@ -125,7 +131,9 @@ async function resolveResource( const logRecord = await getLogById(item.id) if (!logRecord) return { error: `No log with id "${item.id}".` } if (context.workspaceId && logRecord.workspaceId !== context.workspaceId) - return { error: `Log not found in the current workspace.` } + return { + error: `Log "${item.id}" is not in the current workspace — use query_logs to find valid execution ids.`, + } resourceId = logRecord.id const workflowName = logRecord.workflowName ?? 'Unknown Workflow' const timestamp = logRecord.startedAt.toLocaleString('en-US', { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 946cabe015f..2be4f984ac2 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -290,7 +290,11 @@ export async function executeVfsMkdir( if (top === 'tables' || top === 'knowledgebases') { outcomes.push( - folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' } + folderedOutcomes.get(path) ?? { + from: path, + kind, + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, + } ) continue } @@ -312,7 +316,7 @@ export async function executeVfsMkdir( fileOutcomes.get(path) ?? { from: path, kind: 'file_folder', - error: 'File folder creation failed', + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, } ) } else { @@ -320,7 +324,7 @@ export async function executeVfsMkdir( workflowOutcomes.get(path) ?? { from: path, kind: 'workflow_folder', - error: 'Workflow folder creation failed', + error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, } ) } @@ -646,12 +650,16 @@ export async function executeVfsRm( workflowOutcomes.get(path) ?? { from: path, kind: 'workflow', - error: 'Workflow deletion failed', + error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("workflows/*") to confirm before retrying.`, } ) } else if (classified.category === 'files') { outcomes.push( - fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' } + fileOutcomes.get(path) ?? { + from: path, + kind: 'file', + error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("files/**") to confirm before retrying.`, + } ) } else { outcomes.push(await removeOne(classified.category, path, context, workspaceId)) diff --git a/apps/sim/lib/copilot/tools/server/env-reference.ts b/apps/sim/lib/copilot/tools/server/env-reference.ts new file mode 100644 index 00000000000..e2339a22ab5 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/env-reference.ts @@ -0,0 +1,39 @@ +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +/** + * Resolves a whole-value `{{ENV_VAR}}` reference in a secret-bearing tool arg. + * + * Copilot agents never see secret values — the workspace exposes variable + * NAMES only — so when a user says "use the password in CHAT_PW" the model + * passes `{{CHAT_PW}}`. Without resolution the literal seven-character + * placeholder becomes the stored secret and nothing ever errors. Only the + * explicit braced form resolves here: unlike API keys, passwords are + * free-form strings, so `$NAME`/bare-name heuristics would corrupt real ones. + * + * Returns an error when the referenced variable is unset so the model learns + * the actual fix instead of silently storing the placeholder. + */ +export async function resolveEnvReferenceSecretArg(args: { + userId: string + workspaceId?: string + value: string | undefined + argName: string + registry?: ResolvedSecretTraceRegistry +}): Promise<{ value?: string; error?: string }> { + const { value } = args + if (!value) return { value } + const braced = value.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) + if (!braced) return { value } + const name = braced[1] + const env = await getEffectiveDecryptedEnv(args.userId, args.workspaceId) + const resolved = env[name] + if (resolved === undefined || resolved === '') { + return { + error: `Environment variable "${name}" referenced by ${args.argName} is not set for this workspace or user. Set it first, or pass the raw value.`, + } + } + // Activate on the call's egress registry so an accidental echo is redacted. + args.registry?.recordResolved(name, resolved) + return { value: resolved } +} diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index dba8bb892b8..e87e861f9a1 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -166,9 +166,17 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< }) if (!response.ok) { + const hint = + response.status === 401 || response.status === 403 + ? ' — the URL requires authentication this tool cannot supply; ask for a public or pre-signed link instead' + : response.status === 404 + ? ' — the URL does not exist; verify it before retrying' + : response.status === 429 + ? ' — the host is rate-limiting; do not retry immediately' + : ' — the host rejected the request; retrying the same URL will fail again' return { success: false, - message: `Download failed with status ${response.status} ${response.statusText}`, + message: `Download failed with status ${response.status} ${response.statusText}${hint}`, } } diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 99665a3375e..e7ef07e97f4 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { messageForCopilotFileError, @@ -114,7 +115,19 @@ export const editContentServerTool: BaseServerTool 1) { + return { + success: false, + message: `Patch failed: search string matches ${occurrences} places in "${fileRecord.name}". Add surrounding context to make it unique, or pass replaceAll: true to change every occurrence.`, + } } } finalContent = intent.edit.replaceAll diff --git a/apps/sim/lib/copilot/tools/server/files/share-file.ts b/apps/sim/lib/copilot/tools/server/files/share-file.ts index aafa2281f44..e59ae20c81c 100644 --- a/apps/sim/lib/copilot/tools/server/files/share-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/share-file.ts @@ -11,6 +11,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' @@ -60,7 +61,20 @@ export const shareFileServerTool: BaseServerTool const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as | ShareAuthType | undefined - const password = params.password || (nested?.password as string) || undefined + const rawPassword = params.password || (nested?.password as string) || undefined + // "Protect it with the password in {{SHARE_PW}}" arrives as the literal + // reference — resolve it, or the placeholder becomes the real password. + const resolvedPassword = await resolveEnvReferenceSecretArg({ + userId: context.userId, + workspaceId: context.workspaceId, + value: rawPassword, + argName: 'password', + registry: context.resolvedSecretTraceRegistry, + }) + if (resolvedPassword.error) { + return { success: false, message: resolvedPassword.error } + } + const password = resolvedPassword.value const allowedEmails = params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 6ac90e138d3..b34b7271163 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -381,7 +381,10 @@ export const workspaceFileServerTool: BaseServerTool = { return { success: false, message: 'Workspace ID is required' } } if (!VALID_OPERATIONS.includes(params.operation)) { - return { success: false, message: `Invalid operation "${params.operation}".` } + return { + success: false, + message: `Invalid operation "${params.operation}" (allowed: ${VALID_OPERATIONS.join(', ')}).`, + } } const inputPaths = params.inputs?.files?.map((f) => f.path) ?? [] diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts index 272c80d1035..6a517accfb0 100644 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ b/apps/sim/lib/copilot/tools/server/other/search-online.ts @@ -104,7 +104,9 @@ export const searchOnlineServerTool: BaseServerTool - viewConfigNamesToIds( - { - filter: (args.filter as TablePredicateInput | undefined) ?? null, - sort: (args.sort as SortSpec | undefined) ?? null, - hiddenColumns: args.hiddenColumns as string[] | undefined, - } as TableViewConfig, - columns - ) + // Build the patch from only the keys the caller actually sent: the update + // path shallow-merges this into the stored config, so including an absent + // part as `null` silently wiped a view's saved sort when only the filter + // changed (and vice versa) — the doc promises "omit to keep". + const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => { + const patch: Record = {} + if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null + if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null + if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[] + return viewConfigNamesToIds(patch as TableViewConfig, columns) + } switch (operation) { case 'list_views': { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index d53f5ddc203..7d87fbdba63 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1124,7 +1124,7 @@ export async function validateWorkflowSelectorIds( blockType: selector.blockType, field: selector.fieldName, value: selector.value, - error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`, + error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} — they do not exist in this workspace or you lack access. Discover valid ids first (glob/read the matching workspace resource, e.g. environment/credentials.json, knowledgebases/*/meta.json, tables/*/meta.json) instead of guessing${warningInfo}`, }) } else if (result.warning) { // Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors) @@ -1733,7 +1733,7 @@ export async function preValidateCredentialInputs( blockType: credInput.blockType, field: credInput.fieldName, value: credInput.value, - error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`, + error: `Invalid credential ID "${credInput.value}" for ${credInput.blockType}.${credInput.fieldName} — the field was removed from the block. Read environment/credentials.json for connected credential ids, or use oauth_get_auth_link (via the auth agent) to connect the provider first; never invent credential ids${warningInfo}`, }) } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index e28db5e1a2f..5e7decd1596 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -179,12 +179,26 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) - it('projects completed titles only for successful rows', () => { + it('projects a terminal tense for every settled row, present tense only while running', () => { expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows') expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe( 'Comparing workflows' ) - expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows') + // An errored row must not read as still running — the frozen present-tense + // title ("Searching for X" forever) was reported as a stuck tool call. + expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe( + 'Failed comparing workflows' + ) + expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe( + 'Failed searching for admin mentions' + ) + expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe( + 'Stopped comparing workflows' + ) + // Non-gerund titles get a prefix rather than a bad rewrite. + expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe( + 'Failed: Read recent emails' + ) }) }) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 1b5a85f435a..2b359073963 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1136,11 +1136,36 @@ export function getToolCompletedTitle(title: string): string | undefined { return past + title.slice(firstWord.length) } +/** + * Rewrite a resolved display title for a FAILED tool call. A gerund title + * becomes "Failed …" ("Searching for X" → "Failed searching for X"); + * anything else gets a "Failed: " prefix. Without this, an errored row kept + * its present-tense activity title verbatim and read as still running. + */ +export function getToolFailedTitle(title: string): string { + const spaceIndex = title.indexOf(' ') + const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `Failed: ${title}` +} + +/** Rewrite a resolved display title for a CANCELLED tool call ("Stopped …"). */ +export function getToolStoppedTitle(title: string): string { + const spaceIndex = title.indexOf(' ') + const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex) + if (COMPLETED_VERB_REWRITES[firstWord]) { + return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}` + } + return `Stopped: ${title}` +} + /** * Resolve the final title for a tool status at a rendering boundary. Persisted * and live snapshots intentionally keep the present-tense activity title so a - * running/error row remains truthful; every successful renderer calls this to - * project the corresponding completed title from the canonical verb map. + * RUNNING row remains truthful; terminal states project a tense that says the + * work is over — completed (past tense), failed, or stopped. */ export function getToolStatusDisplayTitle( title: string, @@ -1150,5 +1175,8 @@ export function getToolStatusDisplayTitle( if (status === 'success' && toolName === 'browser_request_takeover') { return 'Resumed browser control' } - return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title + if (status === 'success') return getToolCompletedTitle(title) ?? title + if (status === 'error' || status === 'rejected') return getToolFailedTitle(title) + if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title) + return title } diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index dddf4d6a61e..032ff549b11 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -224,6 +224,8 @@ export function serializeRecentExecutions( * but never filters anything. */ export interface KbTagDefinitionSummary { + /** The tagDefinitionId that update_tag / delete_tag / update_document.tagValues require. */ + id: string tagName: string tagSlot: string fieldType: string @@ -872,12 +874,17 @@ export interface DeploymentData { authType: string customizations: unknown isActive: boolean + allowedEmails?: unknown + outputConfigs?: unknown + includeThinking?: boolean | null + includeToolCalls?: boolean | null } | null mcp: Array<{ serverId: string serverName: string toolId: string toolName: string + parameterDescriptionOverrides?: unknown toolDescription?: string | null }> versions?: Array<{ @@ -911,6 +918,9 @@ export function serializeDeployments(data: DeploymentData): string { : { isDeployed: false } if (data.chat) { + // allowedEmails/outputConfigs/includeThinking/includeToolCalls are the + // fields deploy_as_chat accepts on redeploy; exposing the current values is + // what lets a caller change one setting without blanking the others. result.chat = { id: data.chat.id, identifier: data.chat.identifier, @@ -920,6 +930,10 @@ export function serializeDeployments(data: DeploymentData): string { authType: data.chat.authType, customizations: data.chat.customizations, isActive: data.chat.isActive, + allowedEmails: data.chat.allowedEmails ?? undefined, + outputConfigs: data.chat.outputConfigs ?? undefined, + includeThinking: data.chat.includeThinking ?? undefined, + includeToolCalls: data.chat.includeToolCalls ?? undefined, } } @@ -930,6 +944,9 @@ export function serializeDeployments(data: DeploymentData): string { toolId: m.toolId, toolName: m.toolName, toolDescription: m.toolDescription || undefined, + // What deploy_as_mcp accepts as `parameters` on redeploy; omitting it + // there resets the overrides, so expose the current value. + parameterDescriptionOverrides: m.parameterDescriptionOverrides ?? undefined, })) } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 924f3c01f13..e774db56099 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1925,6 +1925,7 @@ export class WorkspaceVFS { documentCount: kb.docCount, connectorTypes: kb.connectorTypes, tagDefinitions: tagDefinitions.map((definition) => ({ + id: definition.id, tagName: definition.displayName, tagSlot: definition.tagSlot, fieldType: definition.fieldType, @@ -2152,6 +2153,10 @@ export class WorkspaceVFS { authType: chatTable.authType, customizations: chatTable.customizations, isActive: chatTable.isActive, + allowedEmails: chatTable.allowedEmails, + outputConfigs: chatTable.outputConfigs, + includeThinking: chatTable.includeThinking, + includeToolCalls: chatTable.includeToolCalls, }) .from(chatTable) .where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))), @@ -2162,6 +2167,7 @@ export class WorkspaceVFS { toolId: workflowMcpTool.id, toolName: workflowMcpTool.toolName, toolDescription: workflowMcpTool.toolDescription, + parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, }) .from(workflowMcpTool) .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 9fd5ba27b57..3357deaad0e 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -95,6 +95,7 @@ export interface ListArchivedKnowledgeBasesResult { } export interface KnowledgeBaseCatalogTagDefinition { + id: string knowledgeBaseId: string tagSlot: string displayName: string @@ -386,6 +387,7 @@ export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({ ? [] : await db .select({ + id: knowledgeBaseTagDefinitions.id, knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, tagSlot: knowledgeBaseTagDefinitions.tagSlot, displayName: knowledgeBaseTagDefinitions.displayName, diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 16ca5d23d8c..1bb5ac7fb1a 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -178,7 +178,11 @@ export async function performCreateKnowledgeConnector( const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig) if (!configValidation.valid) { - return fail(configValidation.error || 'Invalid source configuration', 'validation') + return fail( + configValidation.error || + `The ${connectorType} connector rejected sourceConfig without a reason — re-check its required fields in knowledgebases/connectors/${connectorType}.json before retrying; the same config will fail again.`, + 'validation' + ) } if (connectorConfig.auth.mode === 'apiKey' && apiKey) { diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index d1b8ff08a66..eb7d04ec1a2 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -47,7 +47,10 @@ describe('table application context', () => { it('conceals an asserted cross-workspace table before workspace resolution', async () => { await expect( resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) - ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) + ).rejects.toMatchObject({ + code: 'not_found', + message: expect.stringContaining('not found in this workspace'), + }) expect(loadWorkspace).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts index d87150c0f50..be5c2093ade 100644 --- a/apps/sim/lib/table/application/context.ts +++ b/apps/sim/lib/table/application/context.ts @@ -27,7 +27,12 @@ export async function resolveActiveTableContext(input: { !table || (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) ) { - throw new OrchestrationError('not_found', 'Table not found') + // One message for "no such table" and "table in another workspace" so + // existence never leaks across workspaces — but actionable either way. + throw new OrchestrationError( + 'not_found', + `Table "${input.tableId}" not found in this workspace — it may not exist or may belong to a different workspace. Run glob("tables/*") to list the tables you can use here.` + ) } const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) return { ...workspaceContext, tableId: table.id, table } diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f904d3b22c..4bee5012c47 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -224,7 +224,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({ const table = await getTableById(current.id) if (!table || table.workspaceId !== context.workspaceId) { - throw new OrchestrationError('not_found', 'Table not found') + throw new OrchestrationError( + 'not_found', + 'Table not found in this workspace — run glob("tables/*") to list valid tables' + ) } const index = resolution?.index ?? @@ -295,7 +298,11 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({ const { archived } = await deleteTable(context.table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, }) - if (!archived) throw new OrchestrationError('not_found', 'Table not found') + if (!archived) + throw new OrchestrationError( + 'not_found', + 'Table not found in this workspace — run glob("tables/*") to list valid tables' + ) return { id: context.table.id, deleted: true as const, diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts index 1363079d812..f726d01ccab 100644 --- a/apps/sim/lib/table/application/views.ts +++ b/apps/sim/lib/table/application/views.ts @@ -61,7 +61,11 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!view) throw new OrchestrationError('not_found', 'View not found') + if (!view) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { view, table: context.table } }, }) @@ -130,7 +134,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!existing) throw new OrchestrationError('not_found', 'View not found') + if (!existing) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) const view = await updateTableView({ viewId: input.viewId, tableId: context.table.id, @@ -141,7 +149,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({ isDefault: input.isDefault, columns: (context.table.schema as TableSchema).columns, }) - if (!view) throw new OrchestrationError('not_found', 'View not found') + if (!view) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { view, table: context.table, @@ -181,9 +193,17 @@ export const deleteTableViewUseCase = defineAuthorizedTableUseCase({ (context.table.schema as TableSchema).columns, context.workspaceId ) - if (!existing) throw new OrchestrationError('not_found', 'View not found') + if (!existing) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId) - if (!deleted) throw new OrchestrationError('not_found', 'View not found') + if (!deleted) + throw new OrchestrationError( + 'not_found', + 'View not found on this table — call table_views with operation "list_views" for valid view ids' + ) return { viewId: input.viewId, viewName: existing.name, table: context.table } }, projectAudit({ result }) { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index f4388208a89..923b0ee3463 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -65,19 +65,7 @@ export interface PerformChatDeployResult { export async function performChatDeploy( params: ChatDeployPayload ): Promise { - const { - workflowId, - userId, - identifier, - title, - description = '', - authType = 'public', - password, - allowedEmails = [], - outputConfigs = [], - includeThinking = false, - includeToolCalls = false, - } = params + const { workflowId, userId, identifier, title, password } = params /** * Validate the password here rather than only at the HTTP boundary. The @@ -93,10 +81,60 @@ export async function performChatDeploy( } } + /** + * Redeploys merge: any field the caller omitted keeps the existing chat's + * value instead of being reset to a default. Before this, a copilot + * `deploy_as_chat` call that changed only the title silently flipped an + * email/sso-protected chat back to public, wiped its allowlist and output + * configuration, and reset the welcome customizations — the caller had no + * way to know, because none of those fields were readable back. Defaults + * apply only when there is no existing deployment to preserve. + */ + const [existingDeployment] = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) + + const authType = + params.authType ?? + (existingDeployment?.authType as ChatDeployPayload['authType'] | undefined) ?? + 'public' + const description = + params.description !== undefined ? params.description : (existingDeployment?.description ?? '') + const allowedEmails = + params.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? [] + const outputConfigs = + params.outputConfigs ?? + (existingDeployment?.outputConfigs as Array<{ blockId: string; path: string }> | null) ?? + [] + const includeThinking = params.includeThinking ?? existingDeployment?.includeThinking ?? false + const includeToolCalls = params.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false + + // Per-field merge (params over existing over defaults): callers routinely + // send a customizations object with only some fields set, and a hard default + // for the rest silently reset the chat's colors and welcome message. + const existingCustomizations = + existingDeployment?.customizations && + typeof existingDeployment.customizations === 'object' && + !Array.isArray(existingDeployment.customizations) + ? (existingDeployment.customizations as { + primaryColor?: string + welcomeMessage?: string + imageUrl?: string + }) + : undefined + const mergedImageUrl = params.customizations?.imageUrl || existingCustomizations?.imageUrl const customizations = { - primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)', - welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?', - ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), + primaryColor: + params.customizations?.primaryColor || + existingCustomizations?.primaryColor || + 'var(--brand-hover)', + welcomeMessage: + params.customizations?.welcomeMessage || + existingCustomizations?.welcomeMessage || + 'Hi there! How can I help you today?', + ...(mergedImageUrl ? { imageUrl: mergedImageUrl } : {}), } /** @@ -162,12 +200,6 @@ export async function performChatDeploy( encryptedPassword = encrypted } - const [existingDeployment] = await db - .select() - .from(chat) - .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) - .limit(1) - /** * A password-protected chat must end up with a stored password. Both HTTP * routes already reject this; without the same guard here a copilot diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 575b6da5c35..17f863fce68 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -350,12 +350,29 @@ async function resolveCopilotEnvReferences( return } - const pending: Array<{ paramId: string; value: string }> = [] + // Models improvise reference syntax: after `{{NAME}}`, `$NAME` and the bare + // variable name are the common fallbacks — both previously went upstream as + // the literal credential and failed with an undiagnosable 401. `{{NAME}}` + // and `$NAME` are unambiguous references (a real key never starts with `$`), + // so a missing variable is a hard error. A bare name is a reference only + // when a variable by that exact name exists (`soft`): plenty of real API + // keys match the identifier pattern, and those must pass through verbatim. + const pending: Array<{ paramId: string; value: string; soft?: boolean }> = [] for (const [paramId, paramDef] of Object.entries(tool.params || {})) { if (paramDef?.visibility !== 'user-only') continue const value = params[paramId] - if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + if (typeof value !== 'string') continue + if (value.startsWith('{{') && value.endsWith('}}')) { pending.push({ paramId, value }) + continue + } + const dollar = value.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) + if (dollar) { + pending.push({ paramId, value: `{{${dollar[1]}}}` }) + continue + } + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + pending.push({ paramId, value: `{{${value}}}`, soft: true }) } } @@ -374,7 +391,7 @@ async function resolveCopilotEnvReferences( const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) - for (const { paramId, value } of pending) { + for (const { paramId, value, soft } of pending) { const missingKeys: string[] = [] const resolved = resolveEnvVarReferences(value, envVars, { allowEmbedded: false, @@ -386,6 +403,9 @@ async function resolveCopilotEnvReferences( }, }) if (missingKeys.length > 0) { + // A bare name that matches no variable is treated as the literal + // credential it probably is; only explicit reference forms error. + if (soft) continue const scopeHint = scope.workspaceId ? '' : ' (no workspace context — only personal variables are available here)' diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index efbca622015..80ab44dacf4 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -617,6 +617,16 @@ export function createUserToolSchema( .filter(Boolean) .join(' ') } + // Copilot agents never see secret values, only names — so tell them the + // reference form works here, or they paste placeholders that fail upstream. + if (visibility === 'user-only' && surface === 'copilot') { + propertySchema.description = [ + propertySchema.description, + 'Accepts an environment-variable reference like {{VAR_NAME}} (see environment/variables.json), resolved server-side.', + ] + .filter(Boolean) + .join(' ') + } schema.properties[paramId] = propertySchema if (param.required && paramId !== hostedApiKeyParam) { From d1b9f1b12d48a4f7f077add36f9c76ff1aa327be Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 10:54:53 -0700 Subject: [PATCH 043/135] Harden browser panel and chat cleanup --- .../src/main/browser-agent/panel.test.ts | 24 +++++++++++++++++++ apps/desktop/src/main/browser-agent/panel.ts | 8 +++++++ apps/desktop/src/test/electron-mock.ts | 2 ++ .../[workspaceId]/home/hooks/use-chat.ts | 15 ++++++++---- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts index 4af126c5269..91fd776cd15 100644 --- a/apps/desktop/src/main/browser-agent/panel.test.ts +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -53,6 +53,30 @@ describe('panel chat scope', () => { panel = freshPanel() }) + it('returns keyboard focus to the renderer when attaching a view steals it mid-typing', () => { + const win = new BrowserWindow() + const view = new WebContentsView() + const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false } + vi.mocked(win.webContents.isFocused).mockReturnValue(true) + panel.initPanel({ + getMainWindow: () => win, + activeTab: () => active, + backgroundColor: () => '#0c0c0c', + ensureInitialTab: () => {}, + onViewDetached: () => {}, + }) + panel.activatePanelScope('chat-test') + panel.setPanelBounds(PANEL_RECT, win) + expect(win.contentView.addChildView).toHaveBeenCalledWith(view) + expect(win.webContents.focus).toHaveBeenCalled() + }) + + it('leaves focus untouched when the renderer was not focused at attach time', () => { + const { win, view } = showPanel(panel) + expect(win.contentView.addChildView).toHaveBeenCalledWith(view) + expect(win.webContents.focus).not.toHaveBeenCalled() + }) + it('requires fresh bounds for the newly active chat and ignores stale reports', () => { const { win, view } = showPanel(panel) const previousScope = panel.getActivePanelScopeId() diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 992f2f2b669..293271921b5 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -379,9 +379,17 @@ export function layout(): void { } if (attachedView !== active.view) { + // addChildView hands keyboard focus to the newly attached WebContentsView. + // Agent-driven attaches happen while the user may be typing in the chat + // composer, so if the renderer held focus before the attach, give it back — + // automation drives the page over CDP and never needs OS focus. + const rendererHadFocus = !win.webContents.isDestroyed() && win.webContents.isFocused() win.contentView.addChildView(active.view) hostedWindow = win attachedView = active.view + if (rendererHadFocus) { + win.webContents.focus() + } } bindHostResize(win) const zoom = win.webContents.getZoomFactor() diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index fafa34ee4ac..d1d80f7cef5 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -222,6 +222,8 @@ export class BrowserWindow { getZoomFactor: vi.fn(() => 1), executeJavaScript: vi.fn(() => Promise.resolve(true)), focus: vi.fn(), + isFocused: vi.fn(() => false), + isDestroyed: vi.fn(() => false), send: vi.fn(), setWindowOpenHandler: vi.fn(), isDevToolsOpened: vi.fn(() => false), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 76ef88190cc..6ce4aac3a19 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1197,9 +1197,12 @@ export type ResourceEventHandler = (resourceId: string, options?: ResourceEventO /** * Whether a streamed resource event should activate its tab. Resources switch - * into view as the agent creates or edits them; only the background browser + * into view as the agent creates or edits them; only an already-open browser * session declines to replace an existing selection (it gets an attention - * marker instead), unless the event explicitly requests activation. + * marker instead), unless the event explicitly requests activation — which + * `openBrowserResource` does when it newly opens the browser tab, so agent + * browser work surfaces on first open and stays put once the user has + * deliberately switched away. */ export function shouldActivateResourceEvent( activeResourceId: string | null, @@ -1971,14 +1974,18 @@ export function useChat( const openBrowserResource = useCallback( (activate = false) => { - addResource({ + // A newly opened browser tab surfaces like any other agent-created + // resource. Only an ALREADY-open browser tab stays in the background + // behind another selection — the user saw it and switched away, so + // ongoing agent activity earns an attention marker, not a tab switch. + const newlyOpened = addResource({ type: 'browser', id: BROWSER_SESSION_RESOURCE_ID, title: 'Browser', }) onResourceEventRef.current?.( BROWSER_SESSION_RESOURCE_ID, - activate ? { activate: true } : undefined + activate || newlyOpened ? { activate: true } : undefined ) }, [addResource] From 3d7ba5f9b228c7d48370e60a592b11372c46ebfd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:29:42 -0700 Subject: [PATCH 044/135] Descriptive, user-language tool titles across the board House rules applied everywhere: use every argument the call carries, never name internal machinery, and never lead with Getting (the Got rewrite is deleted so it cannot return). - Deployments name the workflow: Deploying {workflow} as API/chat app/MCP tool - Workflow reads name the part: Reading {workflow} meta/state/deployment/notes; generic reads always name the file (Reading {leaf}), never bare Reading file - Block runs name block and workflow: Running {block} in {workflow}, Running from {block} in {workflow}, Running {workflow} until {block}, and Enabling/Disabling {block} in {workflow} - The six split-table tools get per-operation verbs (Adding column {name}, Updating rows, Wiring automation, Creating view {name}) instead of a wall of Querying table - The manage quartet drops X-action system-speak for gerunds - get_* internal names become user language (Checking run settings, Tracing block inputs, Reading the deployed version); web_fetch says Fetching - Scheduled-task titles removed entirely (feature deleted from the Go catalog) - New verb rewrites: Fetched, Traced, Wired, Configured, Looked, Rotated --- .../message-content/message-content.test.ts | 6 +- .../lib/copilot/tools/tool-display.test.ts | 26 +-- apps/sim/lib/copilot/tools/tool-display.ts | 187 ++++++++++++------ 3 files changed, 146 insertions(+), 73 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 19f953fdacc..d50471c4337 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -483,9 +483,11 @@ describe('completed tool titles', () => { timestamp: 1, }, ]) - ).toBe('Undeployed API') + ).toBe('Undeployed as API') - expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe('Deployed MCP tool') + expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_as_mcp')])).toBe( + 'Deployed as MCP tool' + ) }) it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 5e7decd1596..e3677513bc7 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -72,9 +72,9 @@ describe('humanizeToolName', () => { describe('getToolDisplayTitle natural-language coverage', () => { it('gives gerund titles to tools that previously fell through to humanize', () => { - expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying API') + expect(getToolDisplayTitle('deploy_as_api')).toBe('Deploying as API') expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers') - expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link') + expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Creating sign-in link') expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) @@ -107,16 +107,16 @@ describe('getToolDisplayTitle natural-language coverage', () => { it('resolves every catalog action and operation enum without a generic placeholder', () => { const genericPlaceholders = new Set([ - 'Credential action', - 'Custom tool action', + 'Managing credential', + 'Managing custom tool', 'Editing file', 'Folder action', - 'MCP server action', + 'Managing MCP server', 'Managing knowledge base', 'Managing table', 'Preparing file', 'Processing media', - 'Skill action', + 'Managing skill', ]) const unresolvedVariants: string[] = [] @@ -141,14 +141,14 @@ describe('getToolDisplayTitle natural-language coverage', () => { describe('getToolDisplayTitle for deployments', () => { it.each([ - ['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'], + ['deploy_as_api', undefined, 'Deploying as API'], + ['deploy_as_api', { action: 'deploy' }, 'Deploying as API'], + ['deploy_as_api', { action: 'undeploy' }, 'Undeploying as API'], + ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat app'], + ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat app'], ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], - ['deploy_as_mcp', undefined, 'Deploying MCP tool'], + ['deploy_as_mcp', undefined, 'Deploying as MCP tool'], ['redeploy', undefined, 'Redeploying API'], ])('uses the action and deployment type for %s', (toolName, args, expected) => { expect(getToolDisplayTitle(toolName, args)).toBe(expected) @@ -167,7 +167,7 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') expect(getToolCompletedTitle('Reading file')).toBe('Read file') - expect(getToolCompletedTitle('Undeploying API')).toBe('Undeployed API') + expect(getToolCompletedTitle('Undeploying as API')).toBe('Undeployed as API') expect(getToolCompletedTitle('Duplicating workflow')).toBe('Duplicated workflow') expect(getToolCompletedTitle('Viewing custom tools')).toBe('Viewed custom tools') expect(getToolCompletedTitle('Saving report.pdf')).toBe('Saved report.pdf') diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 907535503f7..89b1e1bdd3c 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -54,8 +54,56 @@ function stringOrNumberArg(args: ToolArgs, key: string): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' } +/** + * Titles for the split table tools: each names its own action, refined by the + * operation and the named target when the args carry one — a card full of + * table work should read as adds, updates, and wiring, never a wall of + * identical "Queried table" rows. + */ +function splitTableTitle(name: string, args: ToolArgs): string { + const op = stringArg(args, 'operation') + const target = firstStringArg(args, 'name', 'columnName', 'viewName', 'tableName', 'title') + const suffix = target ? ` ${target}` : '' + switch (name) { + case 'table_manage': + if (op === 'create') return `Creating table${suffix}` + if (op === 'delete') return `Deleting table${suffix}` + if (op === 'read' || op === 'get' || op === 'list') return 'Reading table' + return 'Updating table' + case 'table_rows': + if (op === 'insert' || op === 'add' || op === 'create') return 'Adding rows' + if (op === 'update') return 'Updating rows' + if (op === 'delete') return 'Deleting rows' + if (op === 'read' || op === 'list' || op === 'query') return 'Reading rows' + return 'Editing rows' + case 'table_columns': + if (op === 'add' || op === 'create') return `Adding column${suffix}` + if (op === 'update') return `Updating column${suffix}` + if (op === 'delete') return `Deleting column${suffix}` + if (op === 'read' || op === 'list') return 'Reading columns' + return 'Editing columns' + case 'table_automations': + if (op === 'read' || op === 'list') return 'Reading automations' + if (op === 'delete') return 'Removing automation' + return 'Wiring automation' + case 'table_enrichments': + if (op === 'read' || op === 'list') return 'Reading enrichments' + if (op === 'delete') return 'Removing enrichment' + return 'Configuring enrichment' + case 'table_views': + if (op === 'create') return `Creating view${suffix}` + if (op === 'delete') return `Deleting view${suffix}` + if (op === 'read' || op === 'list') return 'Reading views' + return 'Editing views' + default: + return 'Updating table' + } +} + function deploymentTitle(args: ToolArgs, deploymentType: string): string { - return `${stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying'} ${deploymentType}` + const verb = stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying' + const workflow = firstStringArg(args, 'workflowName', 'name', 'title') + return workflow ? `${verb} ${workflow} as ${deploymentType}` : `${verb} as ${deploymentType}` } function resourceTypeLabel(type: string): string { @@ -253,19 +301,6 @@ function manageSandboxTitle(args: ToolArgs): string { return titles[stringArg(args, 'operation')] ?? 'Managing sandbox' } -function manageScheduledTaskTitle(args: ToolArgs): string { - const operationArgs = recordArg(args, 'args') - const title = stringArg(operationArgs, 'title') - const titles: Record = { - create: `Creating ${title || 'scheduled task'}`, - list: 'Listing scheduled tasks', - get: 'Reading scheduled task', - update: `Updating ${title || 'scheduled task'}`, - delete: 'Deleting scheduled task', - } - return titles[stringArg(args, 'operation')] ?? 'Managing scheduled task' -} - function userTableTitle(args: ToolArgs): string { const operation = stringArg(args, 'operation') const operationArgs = recordArg(args, 'args') @@ -445,14 +480,14 @@ const TOOL_TITLES: Record = { user_table: 'Managing table', run_code: 'Running code', query_user_table: 'Querying table', - table_manage: 'Managing table', - table_rows: 'Editing table rows', - table_columns: 'Editing table columns', - table_automations: 'Managing table automations', - table_enrichments: 'Managing table enrichments', - table_views: 'Managing table views', + table_manage: 'Updating table', + table_rows: 'Editing rows', + table_columns: 'Editing columns', + table_automations: 'Wiring automation', + table_enrichments: 'Configuring enrichment', + table_views: 'Editing views', prepare_file_edit: 'Editing file', - apply_file_edit: 'Applying file content', + apply_file_edit: 'Writing changes', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', manage_knowledge_base: 'Managing knowledge base', @@ -467,23 +502,21 @@ const TOOL_TITLES: Record = { create_file_folder: 'Creating folder', create_workspace_mcp_server: 'Creating MCP server', delete_workspace_mcp_server: 'Deleting MCP server', - deploy_as_api: 'Deploying API', - deploy_as_chat: 'Deploying chat', + deploy_as_api: 'Deploying as API', + deploy_as_chat: 'Deploying as chat app', publish_custom_block: 'Publishing custom block', - deploy_as_mcp: 'Deploying MCP tool', + deploy_as_mcp: 'Deploying as MCP tool', diff_workflows: 'Comparing workflows', 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_block_outputs: 'Reading block outputs', + get_block_upstream_references: 'Tracing block inputs', + get_deployed_workflow_state: 'Reading the deployed version', list_deployment_versions: 'Listing deployment versions', get_ui_reference: 'Reading UI reference', - get_scheduled_task_logs: 'Reading scheduled task logs', - get_workflow_data: 'Getting workflow data', - get_workflow_run_options: 'Getting run options', + get_workflow_data: 'Reading workflow', + get_workflow_run_options: 'Checking run settings', list_file_folders: 'Listing folders', list_integration_tools: 'Listing integration tools', list_user_workspaces: 'Listing workspaces', @@ -492,11 +525,10 @@ const TOOL_TITLES: Record = { save_upload: 'Saving upload', connect_slack_bot: 'Connecting Slack bot', manage_sandbox: 'Managing sandbox', - manage_scheduled_task: 'Managing scheduled task', move_file: 'Moving file', move_file_folder: 'Moving folder', move_workflow: 'Moving workflow', - oauth_get_auth_link: 'Getting authorization link', + oauth_get_auth_link: 'Creating sign-in link', oauth_request_access: 'Requesting access', promote_to_live: 'Promoting to live', redeploy: 'Redeploying API', @@ -505,13 +537,11 @@ const TOOL_TITLES: Record = { rename_workflow: 'Renaming workflow', restore_resource: 'Restoring resource', run_block: 'Running block', - scheduled_task: 'Managing scheduled task', search_sim_docs: 'Searching Sim docs', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', set_global_workflow_variables: 'Setting workflow variables', update_deployment_version: 'Updating deployment', - update_scheduled_task_history: 'Updating scheduled task history', update_workspace_mcp_server: 'Updating MCP server', // Browser agent tools without an argument-aware title. browser_go_back: 'Going back', @@ -705,7 +735,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_api': return deploymentTitle(args, 'API') case 'deploy_as_chat': - return deploymentTitle(args, 'chat') + return deploymentTitle(args, 'chat app') case 'publish_custom_block': return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': @@ -713,19 +743,18 @@ export function getToolDisplayTitle(name: string, args?: Record case 'manage_knowledge_base': return knowledgeBaseTitle(args) case 'query_user_table': + return queryUserTableTitle(args) case 'table_manage': case 'table_rows': case 'table_columns': case 'table_automations': case 'table_enrichments': case 'table_views': - return queryUserTableTitle(args) + return splitTableTitle(name, args) case 'search_knowledge_base': return searchKnowledgeBaseTitle(args) case 'manage_sandbox': return manageSandboxTitle(args) - case 'manage_scheduled_task': - return manageScheduledTaskTitle(args) case 'user_table': return userTableTitle(args) case 'save_upload': @@ -769,12 +798,6 @@ export function getToolDisplayTitle(name: string, args?: Record const type = stringArg(args, 'type') return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` } - case 'set_block_enabled': { - const enabled = args?.enabled - return typeof enabled === 'boolean' - ? `${enabled ? 'Enabling' : 'Disabling'} block` - : 'Toggling block' - } case 'load_deployment': { const version = stringOrNumberArg(args, 'version') if (!version) return 'Loading deployment' @@ -927,9 +950,9 @@ export function getToolDisplayTitle(name: string, args?: Record } 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` - return 'Getting page contents' + if (urls.length === 1) return `Fetching ${urls[0]}` + if (urls.length > 1) return `Fetching ${urls.length} pages` + return 'Fetching page' } case 'manage_custom_tool': { const schema = args?.schema @@ -938,7 +961,7 @@ export function getToolDisplayTitle(name: string, args?: Record (schema && typeof schema === 'object' ? nestedStringArg(schema as Record, 'function', 'name') : '') - return namedOperationTitle(args, target, 'Custom tool action', { + return namedOperationTitle(args, target, 'Managing custom tool', { add: { verb: 'Creating', resource: 'custom tool' }, edit: { verb: 'Updating', resource: 'custom tool' }, delete: { verb: 'Deleting', resource: 'custom tool' }, @@ -949,7 +972,7 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'serverName', 'name', 'title') || nestedStringArg(args, 'config', 'name') - return namedOperationTitle(args, target, 'MCP server action', { + return namedOperationTitle(args, target, 'Managing MCP server', { add: { verb: 'Creating', resource: 'MCP server' }, edit: { verb: 'Updating', resource: 'MCP server' }, delete: { verb: 'Deleting', resource: 'MCP server' }, @@ -958,7 +981,7 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'manage_skill': { const target = firstStringArg(args, 'name', 'skillName', 'title') - return namedOperationTitle(args, target, 'Skill action', { + return namedOperationTitle(args, target, 'Managing skill', { add: { verb: 'Creating', resource: 'skill' }, edit: { verb: 'Updating', resource: 'skill' }, delete: { verb: 'Deleting', resource: 'skill' }, @@ -974,14 +997,40 @@ export function getToolDisplayTitle(name: string, args?: Record return to ? `Renaming credential to ${to}` : 'Renaming credential' } const target = firstStringArg(args, 'credentialName', 'displayName', 'name', 'title') - return namedOperationTitle(args, target, 'Credential action', { + return namedOperationTitle(args, target, 'Managing credential', { delete: { verb: 'Deleting', resource: 'credential' }, }) } - case 'run_workflow': - case 'run_from_block': - case 'run_workflow_until_block': - return 'Running workflow' + case 'run_workflow': { + const workflow = firstStringArg(args, 'workflowName', 'name') + return workflow ? `Running ${workflow}` : 'Running workflow' + } + case 'run_from_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'startBlockName', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return 'Running workflow' + return workflow ? `Running from ${block} in ${workflow}` : `Running from ${block}` + } + case 'run_workflow_until_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'untilBlockName', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return workflow ? `Running ${workflow}` : 'Running workflow' + return `Running ${workflow || 'workflow'} until ${block}` + } + case 'run_block': { + const block = firstStringArg(args, 'blockName', 'block_name', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + if (!block) return 'Running block' + return workflow ? `Running ${block} in ${workflow}` : `Running ${block}` + } + case 'set_block_enabled': { + const block = firstStringArg(args, 'blockName', 'block_name', 'blockId') + const workflow = firstStringArg(args, 'workflowName', 'name') + const verb = + args?.enabled === false ? 'Disabling' : args?.enabled === true ? 'Enabling' : 'Toggling' + if (!block) return `${verb} block` + return workflow ? `${verb} ${block} in ${workflow}` : `${verb} ${block}` + } case 'query_logs': { // The model narrates its own query; the per-view titles are fallbacks. const title = stringArg(args, 'title') @@ -1007,9 +1056,26 @@ export function getToolDisplayTitle(name: string, args?: Record } } case 'read': { - if (isWorkflowArtifactPath(stringArg(args, 'path'), 'lint.json')) { + const path = stringArg(args, 'path') + if (isWorkflowArtifactPath(path, 'lint.json')) { return 'Validating workflow state' } + // Workflow artifacts name BOTH the workflow and which part, so five + // reads in a row differentiate instead of all saying the same thing. + const workflowArtifact = path.match(/^workflows\/([^/]+)\/([^/]+)$/) + if (workflowArtifact) { + const part = + ( + { + 'meta.json': 'meta', + 'state.json': 'state', + 'deployment.json': 'deployment', + 'README.md': 'notes', + } as Record + )[workflowArtifact[2]] ?? decodePathSegment(workflowArtifact[2]) + return `Reading ${decodePathSegment(workflowArtifact[1])} ${part}` + } + if (path) return `Reading ${pathLeaf(path)}` break } case 'prepare_file_edit': @@ -1063,8 +1129,13 @@ const COMPLETED_VERB_REWRITES: Record = { Finding: 'Found', Gathering: 'Gathered', Generating: 'Generated', - Getting: 'Got', Going: 'Went', + Fetching: 'Fetched', + Tracing: 'Traced', + Wiring: 'Wired', + Configuring: 'Configured', + Looking: 'Looked', + Rotating: 'Rotated', Hovering: 'Hovered', Importing: 'Imported', Inspecting: 'Inspected', From d3a1904515b6afd452e60abf6391b01681ad29bc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:31:23 -0700 Subject: [PATCH 045/135] Deploying {workflow} as chat, not as chat app --- apps/sim/lib/copilot/tools/tool-display.test.ts | 4 ++-- apps/sim/lib/copilot/tools/tool-display.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index e3677513bc7..7ac6bd49b31 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -144,8 +144,8 @@ describe('getToolDisplayTitle for deployments', () => { ['deploy_as_api', undefined, 'Deploying as API'], ['deploy_as_api', { action: 'deploy' }, 'Deploying as API'], ['deploy_as_api', { action: 'undeploy' }, 'Undeploying as API'], - ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat app'], - ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat app'], + ['deploy_as_chat', { action: 'deploy' }, 'Deploying as chat'], + ['deploy_as_chat', { action: 'undeploy' }, 'Undeploying as chat'], ['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'], ['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'], ['deploy_as_mcp', undefined, 'Deploying as MCP tool'], diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 89b1e1bdd3c..73797cccadf 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -503,7 +503,7 @@ const TOOL_TITLES: Record = { create_workspace_mcp_server: 'Creating MCP server', delete_workspace_mcp_server: 'Deleting MCP server', deploy_as_api: 'Deploying as API', - deploy_as_chat: 'Deploying as chat app', + deploy_as_chat: 'Deploying as chat', publish_custom_block: 'Publishing custom block', deploy_as_mcp: 'Deploying as MCP tool', diff_workflows: 'Comparing workflows', @@ -735,7 +735,7 @@ export function getToolDisplayTitle(name: string, args?: Record case 'deploy_as_api': return deploymentTitle(args, 'API') case 'deploy_as_chat': - return deploymentTitle(args, 'chat app') + return deploymentTitle(args, 'chat') case 'publish_custom_block': return `${stringArg(args, 'action') === 'undeploy' ? 'Unpublishing' : 'Publishing'} custom block` case 'ffmpeg': From 6f6e97210af3e83dae2bfcdb42b23e5ee50b87ca Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:36:59 -0700 Subject: [PATCH 046/135] Loader gerunds; mv names both ends; mkdir names the folder search_integration_tools -> Finding the right integration; load_integration_tool -> Loading {integration} tools; load_skill -> Loading skill {name}; run_enrichment -> Looking up {subject}. mv prefers the model's phrasing, else reads 'Moving {files} to {destination}'; mkdir reads 'Creating folder {name}' from the path. --- apps/sim/lib/copilot/tools/tool-display.ts | 25 ++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 73797cccadf..6c4230559e6 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -475,6 +475,9 @@ const TOOL_TITLES: Record = { // covers only the instant before the integration is known. The raw // humanized name ("Call Integration Tool") must never render. call_integration_tool: 'Calling integration', + search_integration_tools: 'Finding the right integration', + load_integration_tool: 'Loading integration tools', + load_skill: 'Loading skill', read: 'Reading file', search_library_docs: 'Searching library docs', user_table: 'Managing table', @@ -877,14 +880,24 @@ export function getToolDisplayTitle(name: string, args?: Record const destination = stringArg(args, 'destination') if (destination) return `Renaming ${pathLeaf(sources[0])} to ${pathLeaf(destination)}` } + // The model's own phrasing wins; otherwise name both ends of the + // move: "Moving apple.md to fruits". const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `${verb} ${target}` : verb + if (target) return `${verb} ${target}` + const destination = stringArg(args, 'destination') + if (sources.length > 0 && destination) { + const what = summarizeTargets(sources.map(pathLeaf), 'files') + return `${verb} ${what} to ${pathLeaf(destination)}` + } + return verb } case 'cp': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Duplicating ${target}` : 'Duplicating workflow' } case 'mkdir': { + const path = stringArg(args, 'path') + if (path) return `Creating folder ${pathLeaf(path)}` const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Creating ${target}` : 'Creating folder' } @@ -896,6 +909,14 @@ export function getToolDisplayTitle(name: string, args?: Record summarizeTargets(stringArrayArg(args, 'paths').map(pathLeaf), 'resource') return target ? `Deleting ${target}` : 'Deleting' } + case 'load_integration_tool': { + const integration = firstStringArg(args, 'integration', 'service', 'toolId') + return integration ? `Loading ${integration} tools` : 'Loading integration tools' + } + case 'load_skill': { + const skill = firstStringArg(args, 'name', 'skillId', 'skill') + return skill ? `Loading skill ${skill}` : 'Loading skill' + } case 'run_enrichment': { const subject = nestedStringArg( args, @@ -906,7 +927,7 @@ export function getToolDisplayTitle(name: string, args?: Record 'email', 'companyDomain' ) - return subject ? `Searching for ${subject}` : 'Searching' + return subject ? `Looking up ${subject}` : 'Looking up data' } case 'web_scrape': { const url = stringArg(args, 'url') From 382c2eb2fb6829519f02998fa2795a40dbf3ac90 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 11:53:42 -0700 Subject: [PATCH 047/135] Overflow counts read '+ n', dropping 'more' --- .../message-content/components/agent-group/agent-group.tsx | 2 +- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index b0aa7beeaac..00b3a81fe37 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -135,7 +135,7 @@ export function AgentGroup({ runningCount += 1 } } - if (running) return runningCount > 1 ? `${running} + ${runningCount - 1} more` : running + if (running) return runningCount > 1 ? `${running} + ${runningCount - 1}` : running return lastAny })() const headerText = status ? `${agentLabel} — ${status}` : agentLabel diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 6c4230559e6..71f4cddcdc5 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -659,7 +659,7 @@ function waitAgentsTitle(args: ToolArgs): string { const anyMode = stringArg(args, 'mode') === 'any' if (names.length === 1) return `Waiting for ${names[0]}` if (names.length > 1) { - const listed = `${names[0]} + ${names.length - 1} more` + const listed = `${names[0]} + ${names.length - 1}` return anyMode ? `Waiting for the first of ${listed}` : `Waiting for ${listed}` } return 'Waiting for agents' From a566c4941e9bec9c8609ab0d712dcec0a48448f7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 12:00:52 -0700 Subject: [PATCH 048/135] Unify workspace find and search --- apps/desktop/src/main/menu.test.ts | 26 ++- apps/desktop/src/main/menu.ts | 50 ++-- .../components/find-bar/find-bar.test.tsx | 183 +++++++++++++++ .../components/find-bar/find-bar.tsx | 153 ++++++++++++ .../[workspaceId]/components/index.ts | 3 + .../components/resource/resource.tsx | 14 +- .../search-highlight/search-highlight.tsx | 0 .../workspace-chrome/workspace-chrome.tsx | 52 ++++- .../workspace/[workspaceId]/files/files.tsx | 116 +++++++++- .../[workspaceId]/home/hooks/use-chat.test.ts | 28 +++ .../[workspaceId]/home/hooks/use-chat.ts | 63 ++++- .../knowledge/[id]/[documentId]/document.tsx | 8 +- .../[workspaceId]/knowledge/[id]/base.tsx | 7 +- .../knowledge/[id]/components/index.ts | 1 - .../[id]/components/search-highlight/index.ts | 1 - .../components/table-grid/constants.ts | 15 +- .../components/table-grid/data-row.tsx | 34 ++- .../components/table-grid/table-find.tsx | 107 --------- .../components/table-grid/table-grid.tsx | 219 +++++++++++++++--- .../tables/[tableId]/search-params.ts | 9 + apps/sim/hooks/queries/tables.ts | 17 +- apps/sim/hooks/use-smooth-text.test.tsx | 36 ++- apps/sim/hooks/use-smooth-text.ts | 41 +++- .../workflows/search-replace/indexer.test.ts | 40 ++++ .../lib/workflows/search-replace/indexer.ts | 15 +- .../search-replace/resources/references.ts | 18 +- .../search-replace/resources/resolvers.ts | 6 +- 27 files changed, 1074 insertions(+), 188 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx rename apps/sim/app/workspace/[workspaceId]/{knowledge/[id] => }/components/search-highlight/search-highlight.tsx (100%) delete mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index faf1648b413..7cd6257f6e9 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -62,20 +62,27 @@ describe('buildMenuTemplate', () => { 'separator', 'quit', ]) - expect(submenu(template, 'File').map((item) => item.label ?? item.role ?? item.type)).toEqual([ + const file = submenu(template, 'File') + expect( + file.filter((item) => item.visible !== false).map((item) => item.label ?? item.type) + ).toEqual(['New Window', 'New Chat', 'separator', 'Close Window']) + // Resource-scoped shortcuts stay registered but never appear in the menu. + expect(file.filter((item) => item.visible === false).map((item) => item.label)).toEqual([ 'New Tab', - 'New Window', - 'New Chat', - 'separator', 'Reopen Closed Tab', 'Focus Address Bar', - 'separator', 'Next Tab', 'Previous Tab', - 'Select Tab', - 'separator', + 'Tab 1', + 'Tab 2', + 'Tab 3', + 'Tab 4', + 'Tab 5', + 'Tab 6', + 'Tab 7', + 'Tab 8', + 'Last Tab', 'Close Tab', - 'Close Window', ]) expect(submenu(template, 'View').map((item) => item.label ?? item.role ?? item.type)).toEqual([ 'Search', @@ -152,7 +159,6 @@ describe('buildMenuTemplate', () => { }) ) const file = submenu(template, 'File') - const selectTabs = submenu(file, 'Select Tab') const focusedWindow = new BrowserWindow() const invoke = (item: MenuItemConstructorOptions | undefined) => (item?.click as unknown as (menuItem: unknown, browserWindow: BrowserWindow) => void)( @@ -162,7 +168,7 @@ describe('buildMenuTemplate', () => { invoke(file.find((item) => item.accelerator === 'Ctrl+Tab')) invoke(file.find((item) => item.accelerator === 'Ctrl+Shift+Tab')) - invoke(selectTabs.find((item) => item.accelerator === 'CmdOrCtrl+9')) + invoke(file.find((item) => item.accelerator === 'CmdOrCtrl+9')) expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(1, focusedWindow, 'next-tab') expect(handleFocusedResourceShortcut).toHaveBeenNthCalledWith(2, focusedWindow, 'previous-tab') diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index 25d4a4c6025..c747447f091 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -63,6 +63,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] return { label: number === 9 ? 'Last Tab' : `Tab ${number}`, accelerator: `CmdOrCtrl+${number}`, + visible: false, click: resourceShortcut(shortcut), } }) @@ -153,13 +154,6 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'File', submenu: [ - { - label: 'New Tab', - accelerator: 'CmdOrCtrl+T', - click: (_item, focusedWindow) => { - deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') - }, - }, { label: 'New Window', accelerator: 'CmdOrCtrl+Shift+N', @@ -167,9 +161,35 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] }, { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: deps.newChat }, { type: 'separator' }, + { + label: 'Close Window', + accelerator: 'CmdOrCtrl+Shift+W', + click: (_item, focusedWindow) => { + const win = focusedOrMain(focusedWindow) + if (win && !win.isDestroyed()) win.close() + }, + }, + /** + * Resource-scoped shortcuts: these act on whichever Browser/Terminal + * panel is focused, not on the app, so they stay out of the visible + * File menu. The accelerators still fire — macOS registers a hidden + * item's accelerator (`acceleratorWorksWhenHidden` defaults to true). + * The numbered tab items sit flat here rather than under a "Select + * Tab" submenu because children of a hidden submenu do not reliably + * register their accelerators. + */ + { + label: 'New Tab', + accelerator: 'CmdOrCtrl+T', + visible: false, + click: (_item, focusedWindow) => { + deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'new-tab') + }, + }, { label: 'Reopen Closed Tab', accelerator: 'CmdOrCtrl+Shift+T', + visible: false, click: (_item, focusedWindow) => { deps.handleFocusedResourceShortcut(focusedOrMain(focusedWindow), 'reopen-closed-tab') }, @@ -177,37 +197,31 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Focus Address Bar', accelerator: 'CmdOrCtrl+L', + visible: false, click: resourceShortcut('focus-omnibox'), }, - { type: 'separator' }, { label: 'Next Tab', accelerator: 'Ctrl+Tab', + visible: false, click: resourceShortcut('next-tab'), }, { label: 'Previous Tab', accelerator: 'Ctrl+Shift+Tab', + visible: false, click: resourceShortcut('previous-tab'), }, - { label: 'Select Tab', submenu: numberedTabItems }, - { type: 'separator' }, + ...numberedTabItems, { label: 'Close Tab', accelerator: 'CmdOrCtrl+W', + visible: false, click: (_item, focusedWindow) => { const win = focusedOrMain(focusedWindow) deps.handleFocusedResourceShortcut(win, 'close-tab') }, }, - { - label: 'Close Window', - accelerator: 'CmdOrCtrl+Shift+W', - click: (_item, focusedWindow) => { - const win = focusedOrMain(focusedWindow) - if (win && !win.isDestroyed()) win.close() - }, - }, ], }, { role: 'editMenu' }, diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx new file mode 100644 index 00000000000..6c2e74dbee7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.test.tsx @@ -0,0 +1,183 @@ +/** + * @vitest-environment jsdom + * + * The find bar's contract with the user: results follow typing (no Enter to + * discover), the counter says which state the search is in, and Enter navigates + * rather than submits. + */ +import { act, createRef, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: { children: ReactNode } & Record) => ( + + ), + ChipInput: ({ + endAdornment, + icon: _icon, + ...props + }: { endAdornment?: ReactNode } & Record) => ( + <> + + {endAdornment} + + ), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => , + ChevronUp: () => , + Loader: () => , + Search: () => , + X: () => , +})) + +import { + FindBar, + type FindBarProps, +} from '@/app/workspace/[workspaceId]/components/find-bar/find-bar' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function render(overrides: Partial = {}) { + const props: FindBarProps = { + ariaLabel: 'Find in table', + query: '', + onQueryChange: vi.fn(), + onNext: vi.fn(), + onPrev: vi.fn(), + onClose: vi.fn(), + count: 0, + currentIndex: 0, + truncated: false, + isLoading: false, + inputRef: createRef(), + ...overrides, + } + act(() => root.render()) + return props +} + +function input(): HTMLInputElement { + const el = container.querySelector('input') + if (!el) throw new Error('find input not rendered') + return el +} + +function counterText(): string | null { + return container.querySelector('[aria-live="polite"]')?.textContent ?? null +} + +function buttonByLabel(label: string): HTMLButtonElement { + const el = container.querySelector(`button[aria-label="${label}"]`) + if (!el) throw new Error(`no button labelled ${label}`) + return el as HTMLButtonElement +} + +function press(key: string, init: KeyboardEventInit = {}) { + act(() => { + input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +describe('FindBar counter', () => { + it('shows nothing before the user has typed', () => { + render({ query: '' }) + expect(counterText()).toBe('') + }) + + it('counts matches as 1-based', () => { + render({ query: 'a', count: 12, currentIndex: 0 }) + expect(counterText()).toBe('1 of 12') + render({ query: 'a', count: 12, currentIndex: 11 }) + expect(counterText()).toBe('12 of 12') + }) + + it('marks a server-capped result set', () => { + render({ query: 'a', count: 1000, currentIndex: 0, truncated: true }) + expect(counterText()).toBe('1 of 1000+') + }) + + it('says No results only once the search has settled', () => { + render({ query: 'zzz', count: 0, isLoading: true }) + expect(counterText()).toBe('') + expect(container.querySelector('[data-icon="loader"]')).not.toBeNull() + + render({ query: 'zzz', count: 0, isLoading: false }) + expect(counterText()).toBe('No results') + }) + + // Blanking the tally on each keystroke reads as the search breaking; the + // previous term's count holds until the new one lands. + it('keeps the previous count visible while the next result set loads', () => { + render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true }) + expect(counterText()).toBe('2 of 3') + }) + + it('keeps the counter mounted and width-reserved before the user types', () => { + render({ query: '' }) + const region = container.querySelector('[aria-live="polite"]') + expect(region).not.toBeNull() + expect(region?.className).toContain('min-w-[64px]') + }) +}) + +describe('FindBar keyboard', () => { + it('navigates on Enter rather than submitting a search', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter') + expect(props.onNext).toHaveBeenCalledTimes(1) + expect(props.onPrev).not.toHaveBeenCalled() + }) + + it('steps backwards on Shift+Enter', () => { + const props = render({ query: 'a', count: 3 }) + press('Enter', { shiftKey: true }) + expect(props.onPrev).toHaveBeenCalledTimes(1) + expect(props.onNext).not.toHaveBeenCalled() + }) + + it('closes on Escape', () => { + const props = render({ query: 'a', count: 3 }) + press('Escape') + expect(props.onClose).toHaveBeenCalledTimes(1) + }) +}) + +describe('FindBar controls', () => { + it('offers a clear button only once there is text', () => { + render({ query: '' }) + expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull() + + const props = render({ query: 'abc' }) + act(() => buttonByLabel('Clear search').click()) + expect(props.onQueryChange).toHaveBeenCalledWith('') + }) + + it('disables navigation while there is nothing to navigate', () => { + render({ query: 'zzz', count: 0 }) + expect(buttonByLabel('Next match').disabled).toBe(true) + expect(buttonByLabel('Previous match').disabled).toBe(true) + + render({ query: 'a', count: 2 }) + expect(buttonByLabel('Next match').disabled).toBe(false) + expect(buttonByLabel('Previous match').disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx new file mode 100644 index 00000000000..b3ed292379c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/find-bar/find-bar.tsx @@ -0,0 +1,153 @@ +'use client' + +import type React from 'react' +import { memo } from 'react' +import { Button, ChipInput } from '@sim/emcn' +import { ChevronDown, ChevronUp, Loader, Search, X } from '@sim/emcn/icons' + +export interface FindBarProps { + /** Accessible name for the input, naming the surface: "Find in table", "Find in files". */ + ariaLabel: string + query: string + onQueryChange: (query: string) => void + onNext: () => void + onPrev: () => void + onClose: () => void + /** Number of matches after dropping any the current view cannot show. */ + count: number + /** 0-based index of the active match. Ignored when `count` is 0. */ + currentIndex: number + /** Whether the producer capped the match set. */ + truncated: boolean + isLoading: boolean + inputRef: React.RefObject +} + +/** + * The find bar every Cmd/Ctrl+F surface shares (tables, files, ...). Purely + * presentational and fully controlled: the surface owns the query, the match + * model and the stepping; this renders the input, the tally and the + * next/prev/close controls. Positioned absolutely against the nearest + * relative container, top-right, the way an in-page find sits in Chrome. + * + * Memoized: while the bar is open it is a child of a view that re-renders on + * scroll, hover and selection. Every prop is a primitive or a stable + * identity, so this collapses to renders where a find value actually changed. + */ +export const FindBar = memo(function FindBar({ + ariaLabel, + query, + onQueryChange, + onNext, + onPrev, + onClose, + count, + currentIndex, + truncated, + isLoading, + inputRef, +}: FindBarProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (e.shiftKey) onPrev() + else onNext() + return + } + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + const hasQuery = query.trim().length > 0 + const hasMatches = count > 0 + + /** The tally holds its last value while the next result set loads — blanking + * it on every keystroke reads as the feature breaking rather than working. */ + function counterContent() { + if (!hasQuery) return null + if (hasMatches) return `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` + return isLoading ? : 'No results' + } + + return ( +
+ onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + // Untrimmed on purpose: whitespace searches nothing, but it is still + // text the user may want cleared. + endAdornment={ + query.length > 0 ? ( + + ) : undefined + } + /> + {/* Always mounted, reserving its width: rendering it only once there is a + query would resize the bar on the first keystroke, and a live region + inserted together with its text is announced unreliably. */} + + {counterContent()} + + + + +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..2fc2f841d16 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,6 +1,8 @@ export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' +export type { FindBarProps } from './find-bar/find-bar' +export { FindBar } from './find-bar/find-bar' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' @@ -38,4 +40,5 @@ export type { } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' export { ResourceTile } from './resource-tile' +export { SearchHighlight } from './search-highlight/search-highlight' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..4723142c047 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -27,6 +27,7 @@ import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inli import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' import { ResourceOptions } from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { SearchHighlight } from '@/app/workspace/[workspaceId]/components/search-highlight/search-highlight' export interface ResourceColumn { id: string @@ -68,6 +69,12 @@ export interface ResourceCell { * layout, and the rename field replaces the label entirely while it is open. */ pinned?: boolean + /** + * Find term to tint inside the label (Cmd/Ctrl+F match). Honoured only on the + * plain label cell, like `pinned` — a `content` cell owns its own rendering + * and the rename field replaces the label while open. + */ + highlight?: string } export interface ResourceRow { @@ -499,6 +506,7 @@ interface CellContentProps { content?: ReactNode editing?: ResourceCellEditing pinned?: boolean + highlight?: string } const CellContent = memo(function CellContent({ @@ -507,6 +515,7 @@ const CellContent = memo(function CellContent({ content, editing, pinned, + highlight, }: CellContentProps) { if (editing) { return ( @@ -526,7 +535,9 @@ const CellContent = memo(function CellContent({ return ( {icon && {icon}} - + + {highlight ? : undefined} + {pinned && ( ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx b/apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/search-highlight.tsx rename to apps/sim/app/workspace/[workspaceId]/components/search-highlight/search-highlight.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 38caceaca27..bd2b0dbbf6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' -import { PanelLeft } from '@sim/emcn/icons' +import { ArrowLeft, ArrowRight, PanelLeft } from '@sim/emcn/icons' import { usePathname } from 'next/navigation' import { getDesktopBridge } from '@/lib/desktop' import { applyDesktopTitleBarMode, type DesktopTitleBarMode } from '@/app/_shell/desktop-title-bar' @@ -86,6 +86,55 @@ interface WorkspaceChromeProps { initialSidebarCollapsed?: boolean } +/** Chromium Navigation API slice (absent from TS lib.dom). */ +type ChromiumNavigation = EventTarget & { canGoBack: boolean; canGoForward: boolean } + +const LANE_NAV_BUTTON = + 'flex size-[var(--desktop-title-bar-control-size)] items-center justify-center rounded-lg transition-colors disabled:pointer-events-none disabled:opacity-40 hover-hover:bg-[var(--surface-active)]' +const LANE_NAV_ICON = 'size-[var(--desktop-title-bar-control-icon-size)] text-[var(--text-icon)]' + +/** + * Back/forward history arrows in the desktop title-bar lane, right of the + * sidebar toggle. Only the macOS shell sets the inset attribute, so the web + * app never shows them; the shell's renderer is Chromium, so the Navigation + * API is always there for the arrow state. + */ +function TitleBarHistoryNav() { + const [can, setCan] = useState({ back: false, forward: false }) + + useEffect(() => { + const nav = (window as { navigation?: ChromiumNavigation }).navigation + if (!nav) return + const sync = () => setCan({ back: nav.canGoBack, forward: nav.canGoForward }) + sync() + nav.addEventListener('currententrychange', sync) + return () => nav.removeEventListener('currententrychange', sync) + }, []) + + return ( +
+ + +
+ ) +} + function isFullscreenPath(pathname: string | null): boolean { return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s)) } @@ -374,6 +423,7 @@ export function WorkspaceChrome({ )} + {!isFullscreen && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..da1fea89382 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -51,12 +51,14 @@ import type { ResourceAction, ResourceColumn, ResourceRow, + ResourceTableHandle, RowDragDropConfig, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FindBar, ownerCell, Resource, timeCell, @@ -191,6 +193,7 @@ const MIME_TYPE_LABELS: Record = { const EMPTY_WORKSPACE_FILES: WorkspaceFileRecord[] = [] const EMPTY_WORKSPACE_FILE_FOLDERS: WorkspaceFileFolderApi[] = [] +const EMPTY_FIND_MATCH_IDS: readonly string[] = Object.freeze([]) const fileRowId = (id: string) => `file:${id}` const folderRowId = (id: string) => `folder:${id}` @@ -674,6 +677,79 @@ export function Files() { }) }, [baseRows, listRename.editingId, listRename.editValue, listRename.isSaving]) + // Find (Cmd/Ctrl+F): the shared find bar over the visible list, stepping + // through rows whose name matches. The list is client-side, so matching is + // synchronous — no debounce or loading states. + const [findOpen, setFindOpen] = useState(false) + const [findQuery, setFindQuery] = useState('') + const [findIndex, setFindIndex] = useState(0) + const findInputRef = useRef(null) + const tableApiRef = useRef(null) + + const trimmedFindQuery = findQuery.trim().toLowerCase() + const findMatchIds = useMemo(() => { + if (!findOpen || trimmedFindQuery.length === 0) return EMPTY_FIND_MATCH_IDS + const ids = rows + .filter((row) => (row.cells.name?.label ?? '').toLowerCase().includes(trimmedFindQuery)) + .map((row) => row.id) + return ids.length > 0 ? ids : EMPTY_FIND_MATCH_IDS + }, [rows, findOpen, trimmedFindQuery]) + const findMatchIdsRef = useRef(findMatchIds) + findMatchIdsRef.current = findMatchIds + const findIndexRef = useRef(findIndex) + findIndexRef.current = findIndex + + const goToFindMatch = useCallback((index: number) => { + const matches = findMatchIdsRef.current + if (matches.length === 0) return + const wrapped = ((index % matches.length) + matches.length) % matches.length + setFindIndex(wrapped) + tableApiRef.current?.scrollToRow(matches[wrapped]) + }, []) + + /** + * A new term resets to and reveals its first match. Keyed on the term, not + * the match set: rows regenerate on renames, uploads and SSE refreshes, and + * re-revealing then would yank a user who has stepped elsewhere back to + * match one. + */ + useEffect(() => { + setFindIndex(0) + if (trimmedFindQuery.length === 0) return + const first = findMatchIdsRef.current[0] + if (first) tableApiRef.current?.scrollToRow(first) + }, [trimmedFindQuery]) + + const handleFindNext = useCallback(() => { + goToFindMatch(findIndexRef.current + 1) + }, [goToFindMatch]) + + const handleFindPrev = useCallback(() => { + goToFindMatch(findIndexRef.current - 1) + }, [goToFindMatch]) + + /** Closing clears the search: term, highlights and cursor all go. */ + const handleFindClose = useCallback(() => { + setFindOpen(false) + setFindQuery('') + setFindIndex(0) + }, []) + + /** + * Rows for the table, with the active term tinted into matching name cells. + * Layered over `rows` so selection, drag-drop and keyboard nav keep reading + * the canonical list. + */ + const displayRows: ResourceRow[] = useMemo(() => { + if (findMatchIds.length === 0) return rows + const matchSet = new Set(findMatchIds) + return rows.map((row) => + matchSet.has(row.id) + ? { ...row, cells: { ...row.cells, name: { ...row.cells.name, highlight: findQuery } } } + : row + ) + }, [rows, findMatchIds, findQuery]) + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) const prevVisibleRowIdsRef = useRef(visibleRowIds) @@ -1575,6 +1651,28 @@ export function Files() { return () => window.removeEventListener('keydown', handleListKeyDown) }, []) + /** + * Overrides the browser's Cmd/Ctrl+F with the in-list find while the list is + * showing. Skipped when a file is open — its editor owns the shortcut there — + * and when another surface already claimed the press. + */ + useEffect(() => { + const handleFindShortcut = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return + if (e.key.toLowerCase() !== 'f') return + if (fileIdFromRouteRef.current) return + if (e.defaultPrevented) return + e.preventDefault() + setFindOpen(true) + requestAnimationFrame(() => { + findInputRef.current?.focus() + findInputRef.current?.select() + }) + } + document.addEventListener('keydown', handleFindShortcut) + return () => document.removeEventListener('keydown', handleFindShortcut) + }, []) + const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -2134,13 +2232,29 @@ export function Files() { /> + {findOpen && ( + + )} ({ }), })) +describe('selectDeletedWorkflowResources', () => { + const resource = (id: string) => ({ type: 'workflow' as const, id, title: id }) + const cached = (id: string) => ({ + id, + name: id, + lastModified: new Date(0), + createdAt: new Date(0), + sortOrder: 0, + }) + + it('selects a hydrated workflow the server no longer has', () => { + expect(selectDeletedWorkflowResources([resource('wf-gone')], new Set(), [])).toEqual([ + resource('wf-gone'), + ]) + }) + + it('keeps a workflow present in the fetched list', () => { + expect(selectDeletedWorkflowResources([resource('wf-1')], new Set(['wf-1']), [])).toEqual([]) + }) + + it('keeps a workflow the stream inserted into the cache after the list snapshot', () => { + expect( + selectDeletedWorkflowResources([resource('wf-new')], new Set(), [cached('wf-new')]) + ).toEqual([]) + }) +}) + describe('shouldActivateResourceEvent', () => { it('keeps background browser activity from replacing another selected resource', () => { expect(shouldActivateResourceEvent('file-1', 'browser-session')).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 6ce4aac3a19..7dec6c4ef60 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -126,8 +126,10 @@ import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' +import { snapAllSmoothText } from '@/hooks/use-smooth-text' import { useExecutionStore } from '@/stores/execution/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { @@ -1189,6 +1191,23 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } +/** + * Hydrated workflow resources whose workflow exists neither in the fetched + * server list nor in the local cache. The cache term protects a workflow the + * agent created after the list snapshot was taken — the stream's registry + * insert lands it in the cache before any refetch does. + */ +export function selectDeletedWorkflowResources( + workflowResources: MothershipResource[], + fetchedWorkflowIds: ReadonlySet, + cachedWorkflows: readonly WorkflowMetadata[] +): MothershipResource[] { + const cachedIds = new Set(cachedWorkflows.map((workflow) => workflow.id)) + return workflowResources.filter( + (resource) => !fetchedWorkflowIds.has(resource.id) && !cachedIds.has(resource.id) + ) +} + export interface ResourceEventOptions { activate?: boolean } @@ -1863,6 +1882,37 @@ export function useChat( } }, []) + /** + * Drops hydrated workflow tabs whose workflow no longer exists, so an old + * chat cannot resurrect a deleted workflow. The check is against a fetched + * workflow list rather than the cache: seeding the registry from the chat's + * persisted resources (what hydration previously did unconditionally) put + * phantom entries in the sidebar that 404 on click. Removal also deletes the + * resource from the chat's persisted set, so the tab stays gone next open. + */ + const reconcileHydratedWorkflowResources = useCallback( + async (chatId: string, workflowResources: MothershipResource[]) => { + let existing: WorkflowMetadata[] + try { + existing = await getQueryClient().fetchQuery(getWorkflowListQueryOptions(workspaceId)) + } catch { + // Existence is unknowable right now; keep the tabs rather than delete + // resources on a network failure. The next hydration retries. + return + } + const deleted = selectDeletedWorkflowResources( + workflowResources, + new Set(existing.map((workflow) => workflow.id)), + getWorkflows(workspaceId) + ) + for (const resource of deleted) { + if ((chatIdRef.current ?? selectedChatIdRef.current) !== chatId) return + removeResource('workflow', resource.id) + } + }, + [workspaceId, removeResource] + ) + const reorderResources = useCallback((newOrder: MothershipResource[]) => { setResources(newOrder) const persistChatId = chatIdRef.current ?? selectedChatIdRef.current @@ -2418,9 +2468,12 @@ export function useChat( setActiveResourceId(hydratedActiveResourceId) } - for (const resource of persistedResources) { - if (resource.type !== 'workflow') continue - ensureWorkflowInRegistry(resource.id, resource.title, workspaceId) + // Restored workflow tabs are verified against the server instead of + // seeded into the registry: a chat can outlive its workflows, and + // fabricating entries for deleted ones polluted the sidebar. + const workflowResources = persistedResources.filter((r) => r.type === 'workflow') + if (workflowResources.length > 0) { + void reconcileHydratedWorkflowResources(chatHistory.id, workflowResources) } } else if (hasPersistedStreamingFile) { activeResourceIdRef.current = null @@ -2505,6 +2558,7 @@ export function useChat( flushPendingResources, openBrowserResource, openTerminalResource, + reconcileHydratedWorkflowResources, recoverPendingClientWorkflowTools, seedPreviewSessions, setTransportIdle, @@ -4619,6 +4673,9 @@ export function useChat( abortControllerRef.current?.abort('user_stop:client_stopGeneration') abortControllerRef.current = null setTransportIdle() + // The paced reveal may still hold up to a drain-horizon of buffered text; + // after an explicit Stop it must not keep typing itself out. + snapAllSmoothText() try { if (activeChatId) { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 974a0896848..00487096bb4 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -20,7 +20,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + SearchHighlight, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -38,7 +42,7 @@ import { documentParsers, documentUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' -import { ActionBar, SearchHighlight } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..9927879f586 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -50,7 +50,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FloatingOverflowText, + Resource, + SearchHighlight, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -66,7 +70,6 @@ import { ConnectorsSection, DocumentContextMenu, RenameDocumentModal, - SearchHighlight, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { addConnectorParam, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts index 12e32ebf736..d26e85dc9e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts @@ -6,4 +6,3 @@ export { ConnectorsSection } from './connectors-section' export { DocumentContextMenu } from './document-context-menu' export { EditConnectorModal } from './edit-connector-modal' export { RenameDocumentModal } from './rename-document-modal' -export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts deleted file mode 100644 index 1144ed165cd..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/search-highlight/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchHighlight } from './search-highlight' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index f369e93d363..af00af4b441 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -1,6 +1,15 @@ /** Tailwind class applied to selected rows / columns / cells. */ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' +/** + * Fill marking every cell matching the active find query. Reuses the app's + * search-highlight token (the knowledge-base search highlight paints with the + * same one) rather than inventing a third match colour, so the two stay + * theme-tuned together. The ACTIVE match is told apart by the selection + * outline drawn over it, not by a different fill. + */ +export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]' + /** Default column width in pixels. Used as a fallback when a column hasn't * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 @@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX = /** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */ export const CELL_CONTENT = 'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small' -export const SELECTION_OVERLAY = - 'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]' +/** Inset shared by every full-cell overlay, so the tints and the selection + * outline can't drift apart on a border-geometry change. */ +export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px' +export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]` diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index acf192e3002..077f73fb2e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -12,6 +12,8 @@ import { CELL, CELL_CHECKBOX, CELL_CONTENT, + CELL_OVERLAY_INSET, + FIND_MATCH_TINT_BG, SELECTION_OVERLAY, SELECTION_TINT_BG, } from './constants' @@ -67,6 +69,13 @@ export interface DataRowProps { pinnedOffsets?: Map /** Key of the rightmost pinned column, used to render a separator shadow. */ lastPinnedColKey?: string | null + /** + * Column keys in this row matching the active find query, tinted so every hit + * is visible at once rather than only the one being navigated to. Absent when + * the row has no match, which is the common case and keeps this row's memo + * from re-running for a search elsewhere in the table. + */ + findMatchColumns?: ReadonlySet } function cellRangeRowChanged( @@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.workflowGroups !== next.workflowGroups || prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || - prev.lastPinnedColKey !== next.lastPinnedColKey + prev.lastPinnedColKey !== next.lastPinnedColKey || + prev.findMatchColumns !== next.findMatchColumns ) { return false } @@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({ activeDispatches, pinnedOffsets, lastPinnedColKey, + findMatchColumns, }: DataRowProps) { const sel = normalizedSelection /** @@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({ const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol const isEditing = editingColumnName === column.key const isHighlighted = inRange || isRowChecked + const isFindMatch = findMatchColumns?.has(column.key) const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked @@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({ data-pinned={isPinnedCell ? '' : undefined} className={cn( CELL, - (isHighlighted || isAnchor || isEditing) && 'relative', + (isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative', isPinnedCell && 'z-[6] bg-[var(--bg)]', isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]' )} @@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({ } onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)} > + {/* No z-index on purpose: with `auto` it paints in DOM order, so it + sits above the cell background but BELOW the cell text, the + selection tint (z-4) and the anchor outline (z-5). The active + match therefore still reads as the selected cell, and the wash + never dims the value it is pointing at. */} + {isFindMatch && ( +
+ )} {isHighlighted && (isMultiCell || isRowChecked) && (
void - /** Run the search (dirty Enter / search button). */ - onSubmit: () => void - onNext: () => void - onPrev: () => void - onClose: () => void - /** Number of matches after dropping columns not in the current view. */ - count: number - /** 0-based index of the active match, or -1 when there are none. */ - currentIndex: number - /** Whether the server capped the match set. */ - truncated: boolean - isLoading: boolean - /** Whether the input differs from the last submitted term. */ - isDirty: boolean - inputRef: React.RefObject -} - -export function TableFind({ - query, - onQueryChange, - onSubmit, - onNext, - onPrev, - onClose, - count, - currentIndex, - truncated, - isLoading, - isDirty, - inputRef, -}: TableFindProps) { - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - if (e.shiftKey) { - onPrev() - } else if (isDirty) { - onSubmit() - } else { - onNext() - } - return - } - if (e.key === 'Escape') { - e.preventDefault() - onClose() - } - } - - const hasMatches = count > 0 - const label = - count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}` - - return ( -
- onQueryChange(e.target.value)} - onKeyDown={handleKeyDown} - /> - - {isLoading ? : label} - - - - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index c2a2e36e3a6..d6d41fc812e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -27,6 +27,8 @@ import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { FindBar } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -62,7 +64,6 @@ import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants' import { DataRow } from './data-row' import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' import { RemoteSelectionOverlay } from './remote-selection-overlay' -import { TableFind } from './table-find' import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives' import type { DisplayColumn } from './types' import { @@ -95,6 +96,7 @@ const logger = createLogger('TableView') const EMPTY_RUNNING_BY_ROW: Readonly> = Object.freeze({}) const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([]) +const EMPTY_FIND_MATCH_COLUMNS: ReadonlyMap> = Object.freeze(new Map()) const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([]) const COL_WIDTH_MIN = 80 @@ -484,11 +486,9 @@ export function TableGrid({ const [selectionFocus, setSelectionFocus] = useState(null) const [rowSelection, setRowSelection] = useState(ROW_SELECTION_NONE) const [isColumnSelection, setIsColumnSelection] = useState(false) - // Find (Cmd/Ctrl+F): `findQuery` is the live input, `submittedQuery` is the - // last Enter/search-triggered term the query hook runs on. + // Find (Cmd/Ctrl+F): `findQuery` is the live input. const [findOpen, setFindOpen] = useState(false) const [findQuery, setFindQuery] = useState('') - const [submittedQuery, setSubmittedQuery] = useState('') const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const [isJumping, setIsJumping] = useState(false) // Bumped on every navigation so the reveal effect re-runs even when the target @@ -496,6 +496,20 @@ export function TableGrid({ const [pendingMatchTick, setPendingMatchTick] = useState(0) const findInputRef = useRef(null) const pendingMatchRef = useRef(null) + /** Cell selected when find was opened, restored on close. */ + const preFindAnchorRef = useRef(null) + /** Last cell find itself moved the selection to, so close can tell a match + * cursor apart from a selection the user made while the bar was open. */ + const lastRevealedAnchorRef = useRef(null) + /** Monotonic id for the in-flight match jump; see `goToMatch`. */ + const goToMatchSeqRef = useRef(0) + /** Term the auto-reveal has already run for, so a background refetch of the + * same term doesn't re-jump the viewport. */ + const autoRevealedTermRef = useRef('') + /** Whether the selection currently sits on the match at `currentMatchIndex`. + * False when the auto-reveal was skipped, so next/prev knows to land on that + * index rather than step past it. */ + const cursorIsOnMatchRef = useRef(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -1098,7 +1112,36 @@ export function TableGrid({ emitCellSelection({ anchor, focus, editing: editingCell !== null }) }, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection]) - const { data: findData, isFetching: isFindFetching } = useFindTableRows({ + /** + * The term the search actually runs on: the live input, debounced so results + * follow typing without a request per keystroke. + * + * Owned here rather than via `useDebounce` because closing or clearing has to + * take effect IMMEDIATELY and cancel anything pending. `useDebounce` is + * trailing-edge and keeps serving its last value until the next timer fires, + * so after Esc it still holds the old term — and a guard on the *input* can't + * mask that, because the first keystroke of the next search makes the input + * non-empty again while the debounce is still holding the previous term. The + * result would be the old search replayed from cache (highlights, count and a + * viewport jump) under a box showing one fresh character. Cmd+F, Esc, Cmd+F + * is an ordinary correction, so that window gets hit. + */ + const trimmedFindQuery = findQuery.trim() + const [submittedQuery, setSubmittedQuery] = useState('') + useEffect(() => { + if (!findOpen || trimmedFindQuery.length === 0) { + setSubmittedQuery('') + return + } + const timer = setTimeout(() => setSubmittedQuery(trimmedFindQuery), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [findOpen, trimmedFindQuery]) + + const { + data: findData, + isFetching: isFindFetching, + isPlaceholderData: isFindPlaceholder, + } = useFindTableRows({ workspaceId, tableId, q: submittedQuery, @@ -1113,6 +1156,11 @@ export function TableGrid({ * to a cell that isn't rendered. */ const findMatches = useMemo(() => { + // `keepPreviousData` serves the previous term's matches while a new term + // loads, which is what keeps the counter steady mid-typing — but with an + // empty term the query is disabled, so that placeholder would otherwise + // linger as highlights over a cleared search box. + if (submittedQuery.length === 0) return EMPTY_FIND_MATCHES const raw = findData?.matches if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES // `m.column` is the stable column id (the JSONB storage key); index display @@ -1125,7 +1173,24 @@ export function TableGrid({ a.ordinal - b.ordinal || (colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0) ) - }, [findData, displayColumns]) + }, [findData, displayColumns, submittedQuery]) + + /** + * Match column ids grouped by row id, so a row can mark its matching cells in + * O(1) without scanning the whole match list. Rebuilt only when the match set + * changes; `DataRow` is memoized on the per-row `Set`, so rows without a match + * keep the same `undefined` and never re-render for a search. + */ + const findMatchColumnsByRowId = useMemo>>(() => { + if (findMatches.length === 0) return EMPTY_FIND_MATCH_COLUMNS + const byRow = new Map>() + for (const match of findMatches) { + const existing = byRow.get(match.rowId) + if (existing) existing.add(match.column) + else byRow.set(match.rowId, new Set([match.column])) + } + return byRow + }, [findMatches]) const findMatchesRef = useRef(findMatches) findMatchesRef.current = findMatches @@ -1142,11 +1207,16 @@ export function TableGrid({ const match = matches[wrapped] setCurrentMatchIndex(wrapped) setIsJumping(true) + // Paging to a distant match can outlast the next keystroke now that the + // search runs as the user types. Stamp this jump and drop it on return if a + // newer one started, or the grid would land on a superseded term's match. + const seq = ++goToMatchSeqRef.current try { await ensureRowsLoadedUpToRef.current(match.ordinal + 1) } finally { - setIsJumping(false) + if (seq === goToMatchSeqRef.current) setIsJumping(false) } + if (seq !== goToMatchSeqRef.current) return // Defer the anchor set to the reveal effect: it must run after the freshly // loaded rows have committed, else scrollToIndex clamps to the stale count. pendingMatchRef.current = match @@ -1171,35 +1241,124 @@ export function TableGrid({ setIsColumnSelection(false) setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setSelectionFocus(null) + lastRevealedAnchorRef.current = { rowIndex, colIndex } + cursorIsOnMatchRef.current = true setSelectionAnchor({ rowIndex, colIndex }) }, [rows, displayColumns, pendingMatchTick]) - /** New result set (new submitted term) → reset to and reveal the first match. */ + /** + * A new TERM resets to its first match and reveals it. + * + * Keyed on the term, not on `findMatches` identity: the find query hangs off + * the rows cache, so any row write or SSE update refetches it, and keying on + * the result set would yank a user reading match 7 back to match 1 whenever + * a workflow cell landed. + * + * The reveal is skipped when the match is outside the loaded window. + * `ensureRowsLoadedUpTo` pages sequentially, so a selective term whose first + * hit is 50k rows down would fire ~50 serial round trips — per typing pause, + * now that the search is live. Highlights and the count still cover the whole + * table; only the viewport jump waits for a deliberate Enter or next-click. + * + * That deliberate path still runs the same unbounded, uncancellable paging it + * always has; this only stops typing from triggering it. Bounding it properly + * wants a fetch-at-offset on the rows endpoint, which is a server change. + */ useEffect(() => { + if (submittedQuery.length === 0) { + // Clearing the box has to un-latch, or retyping the same term — the + // ordinary "did I typo that?" correction — would match the stale latch + // and neither reset the cursor nor reveal anything. It also cancels an + // in-flight jump, exactly as closing does: otherwise a Next still paging + // when the term is cleared lands on a match whose highlight is gone. + autoRevealedTermRef.current = '' + goToMatchSeqRef.current++ + pendingMatchRef.current = null + cursorIsOnMatchRef.current = false + setIsJumping(false) + return + } + // Wait for THIS term's own result set. `keepPreviousData` leaves + // `findMatches` describing the previous term while the new one loads, and + // on the session's first search there is no previous data at all — so + // `isPlaceholderData` is false while the query is still pending. Latching + // in either window would burn the one auto-reveal this term gets. + if (isFindPlaceholder || isFindFetching) return + if (autoRevealedTermRef.current === submittedQuery) return + autoRevealedTermRef.current = submittedQuery setCurrentMatchIndex(0) - if (findMatches.length > 0) goToMatch(0) - }, [findMatches, goToMatch]) - - const handleFindSubmit = useCallback(() => { - setSubmittedQuery(findQuery.trim()) - }, [findQuery]) + cursorIsOnMatchRef.current = false + const first = findMatches[0] + if (!first) return + if (!rowsRef.current.some((r) => r.id === first.rowId)) return + goToMatch(0) + }, [submittedQuery, findMatches, isFindPlaceholder, isFindFetching, goToMatch]) + /** + * Step to the next/previous match — or, when the cursor is not on a match + * yet, to the current index itself. That second case is the term whose first + * hit the auto-reveal skipped because its row wasn't loaded: `+1` there would + * silently step over the very match the user pressed Enter to reach, and it + * would only come back around after wrapping the whole list. + */ const handleFindNext = useCallback(() => { - goToMatch(currentMatchIndexRef.current + 1) + const index = currentMatchIndexRef.current + goToMatch(cursorIsOnMatchRef.current ? index + 1 : index) }, [goToMatch]) const handleFindPrev = useCallback(() => { - goToMatch(currentMatchIndexRef.current - 1) + const index = currentMatchIndexRef.current + goToMatch(cursorIsOnMatchRef.current ? index - 1 : index) }, [goToMatch]) + /** + * Closes the bar and leaves no trace of the search: the term, the highlights + * (via the emptied term), and the match cursor all go. + * + * The cell the user was on before opening find is restored, so an abandoned + * search does not relocate them — Sheets parks the cursor on the last match + * instead, which is a standing complaint there. Restoring is skipped once the + * user has selected a cell themselves: at that point the selection is their + * own work, not find's, and yanking it back would lose their place. + */ const handleFindClose = useCallback(() => { setFindOpen(false) setFindQuery('') - setSubmittedQuery('') + setCurrentMatchIndex(0) pendingMatchRef.current = null + // Strands any jump still paging toward a match, so it can't reveal a cell + // after the bar is gone. + goToMatchSeqRef.current++ + autoRevealedTermRef.current = '' + cursorIsOnMatchRef.current = false + setIsJumping(false) + const origin = preFindAnchorRef.current + const lastRevealed = lastRevealedAnchorRef.current + preFindAnchorRef.current = null + lastRevealedAnchorRef.current = null + const anchor = selectionAnchorRef.current + // A revealed match is a single cell: find sets the anchor and clears the + // focus. A non-null focus means the user extended a range from it + // (Shift+Arrow, Shift+click, drag), which makes the selection theirs even + // though the anchor still sits on the match — restoring would delete it. + const stillOnMatch = + lastRevealed !== null && + anchor !== null && + selectionFocusRef.current === null && + anchor.rowIndex === lastRevealed.rowIndex && + anchor.colIndex === lastRevealed.colIndex + if (stillOnMatch) { + setSelectionFocus(null) + setSelectionAnchor(origin) + } scrollRef.current?.focus({ preventScroll: true }) }, []) + /** The grid's own Escape handler is bound once and closes find through the + * same path as the bar's Escape, so the two can't drift. */ + const handleFindCloseRef = useRef(handleFindClose) + handleFindCloseRef.current = handleFindClose + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. @@ -1507,6 +1666,10 @@ export function TableGrid({ setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null + // Any deliberate click hands the selection back to the user, so closing + // find must not restore over it — including a click on the very cell find + // had revealed, which leaves the anchor and focus looking find-owned. + lastRevealedAnchorRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) } else { @@ -2566,10 +2729,7 @@ export function TableGrid({ if (e.key === 'Escape') { e.preventDefault() if (findOpenRef.current) { - setFindOpen(false) - setFindQuery('') - setSubmittedQuery('') - pendingMatchRef.current = null + handleFindCloseRef.current() return } if (dragColumnNameRef.current) { @@ -3460,6 +3620,10 @@ export function TableGrid({ if (!(e.metaKey || e.ctrlKey) || e.key !== 'f') return if (!containerRef.current) return e.preventDefault() + // Remember where the user was, but only on the transition into find — + // Cmd+F pressed again while the bar is open (to refocus it) must not + // overwrite the origin cell with the match they are currently on. + if (!findOpenRef.current) preFindAnchorRef.current = selectionAnchorRef.current setFindOpen(true) requestAnimationFrame(() => { findInputRef.current?.focus() @@ -4315,18 +4479,20 @@ export function TableGrid({
{findOpen && ( - )} @@ -4634,6 +4800,7 @@ export function TableGrid({ activeDispatches={activeDispatches} pinnedOffsets={pinnedOffsets.size > 0 ? pinnedOffsets : undefined} lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} /> ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts index 5b510dc38e3..dd6776ded92 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/search-params.ts @@ -17,6 +17,15 @@ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc' * recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column * operator objects); serializing it would put a large structured blob in the * URL, which the URL-state doctrine forbids. It stays in local `useState`. + * + * The in-grid `find` (Cmd+F) is likewise absent, for a different reason: it is + * a viewport cursor, not a destination. Two things rule it out. It is not one + * value but a cluster — the term, the match cursor, and the cell the user was + * on before opening find — and only the term is serializable; closing restores + * that pre-find cell from an in-memory ref, so a term that survived a reload + * would arrive with no origin to return to. And the search runs on every + * debounced keystroke rather than on submit, which is the write frequency this + * doctrine keeps out of the URL. Same call the browser's own Cmd+F makes. */ export const tableDetailParsers = { sort: parseAsString, diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7423d6b09de..8610e82d923 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -129,6 +129,14 @@ const logger = createLogger('TableQueries') export const TABLE_DETAIL_STALE_TIME = 30 * 1000 export const TABLE_RUN_STATE_STALE_TIME = 30 * 1000 export const TABLE_FIND_STALE_TIME = 30 * 1000 +/** + * Shorter than the 5-minute default: the grid searches as the user types, so + * each typing pause mints its own cache entry holding up to + * `TABLE_LIMITS.MAX_FIND_MATCHES` matches. Long enough that backspacing to a + * recent term is still instant, short enough that a typed-through term set + * doesn't sit resident. + */ +export const TABLE_FIND_GC_TIME = 60 * 1000 export const TABLE_ROWS_STALE_TIME = 30 * 1000 export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000 @@ -469,9 +477,11 @@ async function fetchTableRowMatches({ } /** - * Server-side find across all cells. `q` is the *submitted* term (search is - * Enter-triggered), so React Query caches each submitted term and re-searching - * a prior one is instant. Disabled while `q` is empty. + * Server-side find across all cells. `q` is the term the caller has settled on + * — the grid debounces the live input before passing it — so React Query caches + * each settled term and backspacing to a prior one is instant. Disabled while + * `q` is empty; `keepPreviousData` holds the last result set so the match count + * doesn't blank between terms. */ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: FindTableRowsParams) { const paramsKey = JSON.stringify({ q, filter: filter ?? null, sort: sort ?? null }) @@ -481,6 +491,7 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }), enabled: Boolean(workspaceId && tableId) && q.trim().length > 0, staleTime: TABLE_FIND_STALE_TIME, + gcTime: TABLE_FIND_GC_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/use-smooth-text.test.tsx b/apps/sim/hooks/use-smooth-text.test.tsx index b4fe89d9911..c80221ddcef 100644 --- a/apps/sim/hooks/use-smooth-text.test.tsx +++ b/apps/sim/hooks/use-smooth-text.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSmoothText } from '@/hooks/use-smooth-text' +import { snapAllSmoothText, useSmoothText } from '@/hooks/use-smooth-text' interface ProbeProps { content: string @@ -89,3 +89,37 @@ describe('useSmoothText — streaming that begins on an already-open document', h.unmount() }) }) + +describe('snapAllSmoothText — user Stop must end the paced reveal instantly', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reveals the full backlog immediately when snapped mid-stream', () => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + probe.rerender({ content: 'The quick brown fox jumps over the lazy dog. '.repeat(4) }) + // Paced reveal has not caught up (fake timers hold the frame loop). + expect(probe.value().length).toBeLessThan(180) + + act(() => { + snapAllSmoothText() + }) + expect(probe.value()).toBe('The quick brown fox jumps over the lazy dog. '.repeat(4)) + probe.unmount() + }) + + it('is one-shot: a later stream paces normally again', () => { + const probe = renderSmoothText({ content: '', isStreaming: true }) + act(() => { + snapAllSmoothText() + }) + probe.rerender({ + content: 'Fresh streaming text that should reveal gradually, not snap. '.repeat(3), + }) + expect(probe.value().length).toBeLessThan(180) + probe.unmount() + }) +}) diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index 0411e8365d8..0b38aa11b47 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -1,4 +1,29 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' + +/** + * Global snap signal: bumping the epoch makes every mounted smooth-text reveal + * jump to its full content immediately. Used by the user Stop path — after an + * explicit abort, watching the buffered tail keep typing itself out reads as + * "my stop didn't work", so the paced reveal must end NOW, not over the drain + * horizon. One-shot per bump: subsequent streams pace normally again. + */ +let snapEpoch = 0 +const snapListeners = new Set<() => void>() + +function subscribeToSnapEpoch(listener: () => void): () => void { + snapListeners.add(listener) + return () => snapListeners.delete(listener) +} + +function getSnapEpoch(): number { + return snapEpoch +} + +/** Snap every mounted smooth-text reveal to its full content immediately. */ +export function snapAllSmoothText(): void { + snapEpoch++ + for (const listener of [...snapListeners]) listener() +} /** * Time-based paced reveal of a growing string. A per-frame loop earns a @@ -111,8 +136,22 @@ export function useSmoothText( const prevContentRef = useRef(content) const prevIsStreamingRef = useRef(isStreaming) + const currentSnapEpoch = useSyncExternalStore(subscribeToSnapEpoch, getSnapEpoch, getSnapEpoch) + const [prevSnapEpoch, setPrevSnapEpoch] = useState(currentSnapEpoch) + let effectiveRevealed = revealed + // A user Stop bumped the snap epoch: reveal everything now instead of + // draining the backlog at the paced cadence. + if (prevSnapEpoch !== currentSnapEpoch) { + setPrevSnapEpoch(currentSnapEpoch) + if (revealed < content.length) { + effectiveRevealed = content.length + revealedRef.current = content.length + setRevealed(content.length) + } + } + if ( isStreaming && !prevIsStreamingRef.current && diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 8b8509107fc..5edcc6bdac4 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -114,6 +114,46 @@ describe('indexWorkflowSearchMatches', () => { expect(blockNameMatches[0]?.fieldTitle).toBe('Block name') }) + it('matches a block name containing a non-breaking space against a typed space', () => { + const workflow = { + blocks: { + 'nbsp-1': { + id: 'nbsp-1', + type: 'function', + name: 'Load\u00a0Prompt', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + } as ReturnType + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'load prompt', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + const blockNameMatches = matches.filter((match) => match.target.kind === 'block-name') + // The raw value keeps the original characters so replacements stay exact. + expect(blockNameMatches.map((match) => match.rawValue)).toEqual(['Load\u00a0Prompt']) + }) + + it('ignores accidental leading/trailing whitespace in the query', () => { + const workflow = createSearchReplaceWorkflowFixture() + + const matches = indexWorkflowSearchMatches({ + workflow, + query: ' agent ', + mode: 'text', + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect(matches.some((match) => match.target.kind === 'block-name')).toBe(true) + }) + it('does not include block-name matches in resource-only mode', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 5b5dccf4307..6bbcd11e975 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -10,6 +10,7 @@ import { shouldParseSerializedSubBlockValue, } from '@/lib/workflows/search-replace/json-value-fields' import { + foldSearchWhitespace, getResourceKindForSubBlock, matchesSearchText, parseInlineReferences, @@ -54,8 +55,14 @@ import { type ToolParameterConfig, } from '@/tools/params' +/** + * Whitespace is folded before comparison (see {@link foldSearchWhitespace}): + * the fold is one-to-one, so ranges found in the normalized string index the + * original text correctly. + */ function normalizeForSearch(value: string, caseSensitive: boolean): string { - return caseSensitive ? value : value.toLowerCase() + const folded = foldSearchWhitespace(value) + return caseSensitive ? folded : folded.toLowerCase() } function findTextRanges(value: string, query: string, caseSensitive: boolean) { @@ -1233,7 +1240,7 @@ export function indexWorkflowSearchMatches( ): WorkflowSearchMatch[] { const { workflow, - query, + query: rawQuery, mode = 'all', caseSensitive = false, includeResourceMatchesWithoutQuery = false, @@ -1248,6 +1255,10 @@ export function indexWorkflowSearchMatches( mcpToolNamesById, } = options + // Match on the trimmed query: an accidental leading/trailing space (easy to + // type, impossible to see in the search box) must not hide every match. + const query = rawQuery?.trim() + const matches: WorkflowSearchMatch[] = [] const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query) diff --git a/apps/sim/lib/workflows/search-replace/resources/references.ts b/apps/sim/lib/workflows/search-replace/resources/references.ts index ad2619f9e66..d0ba97ce4ee 100644 --- a/apps/sim/lib/workflows/search-replace/resources/references.ts +++ b/apps/sim/lib/workflows/search-replace/resources/references.ts @@ -75,13 +75,27 @@ export function parseStructuredResourceReferences( return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext) } +/** + * Maps every Unicode whitespace character to a plain space, one-to-one. + * Agent-authored block names and values routinely carry non-breaking or + * narrow spaces that render identically to " " but never equal a typed + * space, silently hiding matches. The replacement is length-preserving + * (every `\s` character is a single UTF-16 unit), so indexes into the + * folded string remain valid ranges into the original. + */ +export function foldSearchWhitespace(value: string): string { + return value.replace(/\s/g, ' ') +} + export function matchesSearchText( candidate: string, query: string | undefined, caseSensitive = false ): boolean { if (!query) return true - const source = caseSensitive ? candidate : candidate.toLowerCase() - const target = caseSensitive ? query : query.toLowerCase() + const foldedCandidate = foldSearchWhitespace(candidate) + const foldedQuery = foldSearchWhitespace(query) + const source = caseSensitive ? foldedCandidate : foldedCandidate.toLowerCase() + const target = caseSensitive ? foldedQuery : foldedQuery.toLowerCase() return source.includes(target) } diff --git a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts index 96464f3bd89..29368d41859 100644 --- a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts +++ b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts @@ -1,3 +1,4 @@ +import { foldSearchWhitespace } from '@/lib/workflows/search-replace/resources/references' import type { WorkflowSearchMatch, WorkflowSearchMatchKind, @@ -241,7 +242,10 @@ export function workflowSearchMatchMatchesQuery( if (!trimmedQuery) return false if (match.kind === 'text') return true - const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase()) + const normalize = (value: string) => { + const folded = foldSearchWhitespace(value) + return caseSensitive ? folded : folded.toLowerCase() + } const searchable = match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment' ? [match.displayLabel, match.rawValue, match.searchText] From 833d2f126dcee0ee6c51e6dff25342b3019ce8fd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 13:24:04 -0700 Subject: [PATCH 049/135] Scale desktop title bar with page zoom --- apps/sim/app/_styles/globals.css | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index ae53a936065..cdbf904c647 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -89,23 +89,24 @@ } /** - * Electron's `titleBarOverlay` publishes the window controls' geometry as these - * `titlebar-area-*` env vars, and Chromium rescales them under page zoom so the - * lane holds its physical size. Fallbacks are the platform's measured values, for - * a shell predating the overlay; the `:root` zeros cover the different case of the - * attribute being absent entirely, where this block never matches. + * The lane scales with page zoom like the rest of the UI (Codex-style): the + * 38px/81px terms are CSS px, so zooming in grows the lane and its controls + * with the content while the OS-drawn traffic lights hold their physical size + * inside it. The `env(titlebar-area-*)` terms — which Chromium rescales under + * zoom to keep physical geometry — act only as a floor, so zooming OUT can + * never shrink the lane under the lights or slide the controls beneath them. + * Fallbacks are the platform's measured values, for a shell predating the + * overlay; the `:root` zeros cover the different case of the attribute being + * absent entirely, where this block never matches. */ html[data-sim-desktop-title-bar="inset"] { --sidebar-collapsed-width: 0px; - --desktop-title-bar-height: env(titlebar-area-height, 38px); - --desktop-title-bar-inset-x: env(titlebar-area-x, 81px); - /* 0.79 = 30px of the 38px lane, and 0.53 of that = 16px. Proportions rather - than px because px would scale with page zoom while the OS-drawn lights - would not — and calc cannot divide a length by a length to get a scale. - `navigator.windowControlsOverlay` could, but reading it in JS would race - first paint for a value the blocking script needs. */ - --desktop-title-bar-control-size: calc(var(--desktop-title-bar-height) * 0.79); - --desktop-title-bar-control-icon-size: calc(var(--desktop-title-bar-control-size) * 0.53); + --desktop-title-bar-height: max(env(titlebar-area-height, 38px), 38px); + --desktop-title-bar-inset-x: max(env(titlebar-area-x, 81px), 81px); + /* 30px of the 38px lane, and 16px of that — CSS px on purpose, so the + controls zoom with the page instead of staying pinned to physical size. */ + --desktop-title-bar-control-size: 30px; + --desktop-title-bar-control-icon-size: 16px; --desktop-title-bar-control-offset: calc( (var(--desktop-title-bar-height) - var(--desktop-title-bar-control-size)) / 2 From 34a5edb82017030c835dc407c1adb69902ecb9dc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 14:31:09 -0700 Subject: [PATCH 050/135] Serialize account and organization truth into the copilot VFS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace standing, membership, billing, org role, access-control restrictions, published-block provenance, and fork topology were reachable only through three parameterless tools (or not at all). They are ambient read-only facts, so they belong in the VFS where they are greppable, cost no tool round-trip, and every agent that can read gets them — the same move that retired get_blocks_and_tools and list_user_workflows. Adds account/{workspace,workspaces,members,billing}.json (always mounted) and organization/{organization,access-control,custom-blocks,forks}.json (only when the workspace is org-hosted). Every file projects an existing use case or util after getOrMaterializeVFS's access assert — no new queries, no new authorization. One relation per file, cross-referenced by id-and-name stub, so overlapping facts cannot disagree. Volatile content (billing, access control, forks) is lazy, so numbers are read-time fresh and unasked-for reads cost nothing. Projection follows the viewer: member emails are admin-only, fork detail requires workspace admin on a forking-enabled org, and the whole organization/ namespace is absent for a personal workspace — which is itself the answer. Retires get_account_billing, get_enterprise_context, and list_user_workspaces along with their handlers; display titles stay for transcript replay. --- apps/sim/lib/copilot/entitlements.ts | 14 + .../lib/copilot/generated/tool-catalog-v1.ts | 33 -- .../lib/copilot/generated/tool-schemas-v1.ts | 21 - .../tool-executor/register-handlers.ts | 9 - .../copilot/tools/handlers/account.test.ts | 107 ----- .../sim/lib/copilot/tools/handlers/account.ts | 27 -- .../tools/handlers/enterprise-context.test.ts | 367 ------------------ .../tools/handlers/enterprise-context.ts | 34 -- .../tools/handlers/workflow/queries.test.ts | 52 +-- .../tools/handlers/workflow/queries.ts | 21 - apps/sim/lib/copilot/tools/tool-display.ts | 2 + apps/sim/lib/copilot/vfs/serializers.test.ts | 198 ++++++++++ apps/sim/lib/copilot/vfs/serializers.ts | 304 +++++++++++++++ apps/sim/lib/copilot/vfs/workspace-vfs.ts | 260 ++++++++++++- 14 files changed, 778 insertions(+), 671 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/account.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/account.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/enterprise-context.ts diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts index f35e356b8d0..99219bc8372 100644 --- a/apps/sim/lib/copilot/entitlements.ts +++ b/apps/sim/lib/copilot/entitlements.ts @@ -3,6 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotEntitlements') @@ -12,6 +13,7 @@ const logger = createLogger('CopilotEntitlements') */ export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks' export const SIM_SANDBOXES_ENTITLEMENT = 'sim-sandboxes' +export const ORGANIZATION_CONTEXT_ENTITLEMENT = 'organization-context' /** * Workspace entitlements — plan/flag-gated org capabilities sent to the @@ -36,6 +38,18 @@ const ENTITLEMENT_EVALUATORS: Record< > = { [CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible, [SIM_SANDBOXES_ENTITLEMENT]: hasWorkspaceSandboxAccess, + [ORGANIZATION_CONTEXT_ENTITLEMENT]: isOrganizationContextAvailable, +} + +/** + * True when this workspace belongs to an organization, which is exactly when + * the copilot's `organization/` VFS namespace has anything in it. Advertising + * it keeps a personal workspace's agents from ever hearing that org standing, + * access-control groups, or fork topology exist. + */ +async function isOrganizationContextAvailable(workspaceId: string): Promise { + const workspace = await getWorkspaceWithOwner(workspaceId) + return Boolean(workspace?.organizationId) } const entitlementsCache = new LRUCache>({ diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index b31faaad985..8164a205757 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -55,12 +55,10 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' - | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_status' - | 'get_enterprise_context' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -69,7 +67,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_user_workspaces' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -186,12 +183,10 @@ export interface ToolCatalogEntry { | 'generate_audio' | 'generate_image' | 'generate_video' - | 'get_account_billing' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' | 'get_deployment_status' - | 'get_enterprise_context' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -200,7 +195,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_user_workspaces' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -3004,14 +2998,6 @@ export const GenerateVideo: ToolCatalogEntry = { capabilities: ['file_input', 'file_output', 'generated_media'], } -export const GetAccountBilling: ToolCatalogEntry = { - id: 'get_account_billing', - name: 'get_account_billing', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const GetBlockOutputs: ToolCatalogEntry = { id: 'get_block_outputs', name: 'get_block_outputs', @@ -3089,14 +3075,6 @@ export const GetDeploymentStatus: ToolCatalogEntry = { }, } -export const GetEnterpriseContext: ToolCatalogEntry = { - id: 'get_enterprise_context', - name: 'get_enterprise_context', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -3287,14 +3265,6 @@ export const ListIntegrationTools: ToolCatalogEntry = { }, } -export const ListUserWorkspaces: ToolCatalogEntry = { - id: 'list_user_workspaces', - name: 'list_user_workspaces', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - export const ListWorkspaceMcpServers: ToolCatalogEntry = { id: 'list_workspace_mcp_servers', name: 'list_workspace_mcp_servers', @@ -7245,12 +7215,10 @@ export const TOOL_CATALOG: Record = { [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, [GenerateVideo.id]: GenerateVideo, - [GetAccountBilling.id]: GetAccountBilling, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentStatus.id]: GetDeploymentStatus, - [GetEnterpriseContext.id]: GetEnterpriseContext, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -7259,7 +7227,6 @@ export const TOOL_CATALOG: Record = { [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, - [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 06300581869..b4339923a66 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2959,13 +2959,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_account_billing: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_block_outputs: { parameters: { type: 'object', @@ -3034,13 +3027,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_enterprise_context: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -3209,13 +3195,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_user_workspaces: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, list_workspace_mcp_servers: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 8baf0178d44..a598d4135e7 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -10,19 +10,16 @@ import { DeployAsMcp, DiffWorkflows, GenerateApiKey, - GetAccountBilling, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentStatus, - GetEnterpriseContext, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, Grep as GrepTool, ListDeploymentVersions, ListIntegrationTools, - ListUserWorkspaces, ListWorkspaceMcpServers, LoadDeployment, ManageCredential, @@ -53,8 +50,6 @@ import { UpdateDeploymentVersion, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' -import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' -import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' @@ -114,7 +109,6 @@ import { executeGetDeployedWorkflowState, executeGetWorkflowData, executeGetWorkflowRunOptions, - executeListUserWorkspaces, } from '../tools/handlers/workflow/queries' import { registerHandlers } from './executor' import type { ToolHandler } from './types' @@ -139,9 +133,6 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { function buildHandlerMap(): Record { return { - [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), - [GetAccountBilling.id]: h((_p, c) => executeGetAccountBilling(c)), - [GetEnterpriseContext.id]: h((_p, c) => executeGetEnterpriseContext(c)), [GetWorkflowData.id]: h(executeGetWorkflowData), [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), diff --git a/apps/sim/lib/copilot/tools/handlers/account.test.ts b/apps/sim/lib/copilot/tools/handlers/account.test.ts deleted file mode 100644 index c8ac0435a45..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/account.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - loadWorkspace: vi.fn(), - resolvePermission: vi.fn(), - getAccountBillingSnapshot: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) - -vi.mock('@/lib/workspaces/application/workspace-context', () => ({ - loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, -})) - -vi.mock('@/lib/billing/core/account-billing-snapshot', () => ({ - getAccountBillingSnapshot: mocks.getAccountBillingSnapshot, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeGetAccountBilling } from '@/lib/copilot/tools/handlers/account' - -const context = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - copilotInteractionMode: 'interactive', -} as const satisfies ExecutionContext - -const snapshot = { - plan: 'team', - billingScope: 'organization' as const, - organizationId: 'org-1', - usage: { - currentPeriodCost: 18.5, - limit: 40, - remaining: 21.5, - percentUsed: 46.25, - isExceeded: false, - billingPeriodEnd: new Date('2026-09-01T00:00:00Z'), - }, - credits: { balance: 25, scope: 'organization' as const }, -} - -describe('executeGetAccountBilling', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.loadWorkspace.mockResolvedValue({ - workspaceId: 'workspace-1', - workspaceOrganizationId: 'org-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'owner-1', - }) - mocks.resolvePermission.mockResolvedValue('read') - mocks.getAccountBillingSnapshot.mockResolvedValue(snapshot) - }) - - it('returns the existing account billing tool result shape after authorization', async () => { - await expect(executeGetAccountBilling(context)).resolves.toEqual({ - success: true, - output: snapshot, - }) - expect(mocks.getAccountBillingSnapshot).toHaveBeenCalledWith('user-1') - }) - - it.each(['headless' as const, undefined])( - 'fails closed for a non-interactive lifecycle (%s) before protected lookup', - async (copilotInteractionMode) => { - const result = await executeGetAccountBilling({ - ...context, - copilotInteractionMode, - }) - - expect(result).toEqual({ - success: false, - error: 'Live platform context is available only in an interactive Copilot session.', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() - expect(mocks.resolvePermission).not.toHaveBeenCalled() - expect(mocks.getAccountBillingSnapshot).not.toHaveBeenCalled() - } - ) - - it('does not expose an underlying billing failure', async () => { - mocks.getAccountBillingSnapshot.mockRejectedValue( - new Error('connection secret from billing database') - ) - - await expect(executeGetAccountBilling(context)).resolves.toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/account.ts b/apps/sim/lib/copilot/tools/handlers/account.ts deleted file mode 100644 index 051b46f931f..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/account.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - executeCopilotPlatformContextUseCase, - messageForCopilotPlatformContextError, -} from '@/lib/copilot/application/execute-platform-context-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { readAccountBilling } from '@/lib/platform-context/application/read-account-billing' - -/** - * Live billing snapshot for the requesting user: plan, current-period usage - * against its limit, and purchased credit balance. All three sources are - * org-aware — a member whose subscription lives on an organization gets the - * org's plan, limit, and credit pool, with `billingScope`/`organizationId` - * saying which applied. - */ -export async function executeGetAccountBilling(context: ExecutionContext): Promise { - try { - const output = await executeCopilotPlatformContextUseCase(context, readAccountBilling, { - workspaceId: context.workspaceId ?? '', - }) - return { - success: true, - output, - } - } catch (error) { - return { success: false, error: messageForCopilotPlatformContextError(error) } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts deleted file mode 100644 index 231692c6a76..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetWorkspaceHostContextForViewer, - mockResolveVerifiedUserAccessControlContext, - mockLoadWorkspace, - mockResolvePermission, -} = vi.hoisted(() => ({ - mockGetWorkspaceHostContextForViewer: vi.fn(), - mockResolveVerifiedUserAccessControlContext: vi.fn(), - mockLoadWorkspace: vi.fn(), - mockResolvePermission: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: mockResolvePermission, -})) - -vi.mock('@/lib/workspaces/application/workspace-context', () => ({ - loadActiveWorkspaceApplicationContext: mockLoadWorkspace, -})) - -vi.mock('@/lib/workspaces/host-context', () => ({ - getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - resolveVerifiedUserAccessControlContext: mockResolveVerifiedUserAccessControlContext, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeGetEnterpriseContext } from '@/lib/copilot/tools/handlers/enterprise-context' -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/types' - -const context = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - copilotInteractionMode: 'interactive', -} as const satisfies ExecutionContext - -function enterpriseHost(permission: 'read' | 'write' | 'admin') { - return { - workspace: { - id: 'workspace-1', - name: 'Customer Support', - workspaceMode: 'collaborative', - billedAccountUserId: 'owner-1', - }, - hostOrganizationId: 'org-1', - ownerBilling: { - plan: 'enterprise', - status: 'active', - isPaid: true, - isPro: true, - isTeam: true, - isEnterprise: true, - isOrgScoped: true, - organizationId: 'org-1', - billingInterval: 'year', - billingBlocked: false, - billingBlockedReason: null, - }, - viewer: { - permission, - isHostOrganizationMember: false, - isHostOrganizationAdmin: false, - organizationRole: null, - }, - } -} - -describe('executeGetEnterpriseContext', () => { - beforeEach(() => { - vi.clearAllMocks() - mockLoadWorkspace.mockResolvedValue({ - workspaceId: 'workspace-1', - workspaceOrganizationId: 'org-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'owner-1', - }) - mockResolvePermission.mockResolvedValue('read') - }) - - it('requires a current workspace', async () => { - const result = await executeGetEnterpriseContext({ userId: 'user-1' } as ExecutionContext) - - expect(result).toEqual({ - success: false, - error: 'A current workspace is required to resolve enterprise access.', - }) - expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() - }) - - it('rejects headless execution before loading workspace or enterprise context', async () => { - const result = await executeGetEnterpriseContext({ - ...context, - copilotInteractionMode: 'headless', - }) - - expect(result).toEqual({ - success: false, - error: 'Live platform context is available only in an interactive Copilot session.', - }) - expect(mockLoadWorkspace).not.toHaveBeenCalled() - expect(mockGetWorkspaceHostContextForViewer).not.toHaveBeenCalled() - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('keeps external workspace administration separate from organization authority', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: { - id: 'group-1', - name: 'Contractors', - resolution: 'all-members', - }, - config: { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - allowedIntegrations: ['slack'], - deniedTools: ['slack_delete_message'], - disableMcpTools: true, - disableInvitations: true, - }, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( - 'user-1', - 'workspace-1', - 'org-1' - ) - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - id: 'workspace-1', - permission: 'admin', - capabilities: { - canRead: true, - canEdit: true, - canRun: true, - canDeploy: true, - canManageWorkspace: true, - }, - }, - organization: { - id: 'org-1', - relationship: 'external', - role: null, - canManageOrganization: false, - canManageBilling: false, - plan: 'enterprise', - isEnterprise: true, - }, - accessControl: { - entitled: true, - governingPermissionGroup: { - id: 'group-1', - name: 'Contractors', - resolution: 'all-members', - }, - effectiveConfig: expect.objectContaining({ disableMcpTools: true }), - activeRestrictions: expect.arrayContaining([ - expect.objectContaining({ key: 'allowedIntegrations' }), - expect.objectContaining({ key: 'deniedTools' }), - expect.objectContaining({ key: 'disableMcpTools' }), - expect.objectContaining({ key: 'disableInvitations' }), - ]), - }, - }, - }) - }) - - it('reports an internal member role without granting organization administration', async () => { - const host = enterpriseHost('write') - mockGetWorkspaceHostContextForViewer.mockResolvedValue({ - ...host, - viewer: { - ...host.viewer, - isHostOrganizationMember: true, - organizationRole: 'member', - }, - }) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - permission: 'write', - capabilities: { - canRead: true, - canEdit: true, - canRun: true, - canDeploy: false, - canManageWorkspace: false, - }, - }, - organization: { - relationship: 'internal', - role: 'member', - canManageOrganization: false, - canManageBilling: false, - }, - }, - }) - }) - - it('reports read access without write, run, deployment, or administration capabilities', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('read')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - permission: 'read', - capabilities: { - canRead: true, - canEdit: false, - canRun: true, - canDeploy: false, - canManageWorkspace: false, - }, - }, - }, - }) - }) - - it('does not advertise deployment when every deployment surface is hidden', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('admin')) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: 'org-1', - entitled: true, - permissionGroup: null, - config: { - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideDeployApi: true, - hideDeployMcp: true, - hideDeployChatbot: true, - }, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { - capabilities: { - canRun: true, - canDeploy: false, - }, - }, - }, - }) - }) - - it('returns a personal-workspace context without looking up organization membership', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue({ - ...enterpriseHost('write'), - hostOrganizationId: null, - ownerBilling: { - ...enterpriseHost('write').ownerBilling, - plan: 'pro', - isEnterprise: false, - isOrgScoped: false, - organizationId: null, - }, - }) - mockResolveVerifiedUserAccessControlContext.mockResolvedValue({ - organizationId: null, - entitled: false, - permissionGroup: null, - config: null, - }) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toMatchObject({ - success: true, - output: { - workspace: { permission: 'write' }, - organization: null, - accessControl: { - entitled: false, - governingPermissionGroup: null, - effectiveConfig: null, - activeRestrictions: [], - }, - }, - }) - expect(mockResolveVerifiedUserAccessControlContext).toHaveBeenCalledWith( - 'user-1', - 'workspace-1', - null - ) - }) - - it('does not expose enterprise context when workspace access cannot be resolved', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(null) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'Workspace not found or you do not have access.', - }) - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('returns a failure when workspace context resolution fails', async () => { - mockGetWorkspaceHostContextForViewer.mockRejectedValue(new Error('workspace lookup failed')) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - expect(mockResolveVerifiedUserAccessControlContext).not.toHaveBeenCalled() - }) - - it('returns a failure when access-control resolution fails', async () => { - mockGetWorkspaceHostContextForViewer.mockResolvedValue(enterpriseHost('write')) - mockResolveVerifiedUserAccessControlContext.mockRejectedValue( - new Error('access-control lookup failed') - ) - - const result = await executeGetEnterpriseContext(context) - - expect(result).toEqual({ - success: false, - error: 'The operation failed due to a system error. Please retry.', - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts b/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts deleted file mode 100644 index d72f7ae1db0..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/enterprise-context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - executeCopilotPlatformContextUseCase, - messageForCopilotPlatformContextError, -} from '@/lib/copilot/application/execute-platform-context-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { readEnterpriseContext } from '@/lib/platform-context/application/read-enterprise-context' - -/** - * Resolves the authenticated user's effective Enterprise access in the current - * workspace. This is an explanatory snapshot; every later mutation must still - * perform its normal server-side authorization at execution time. - */ -export async function executeGetEnterpriseContext( - context: ExecutionContext -): Promise { - if (!context.workspaceId) { - return { - success: false, - error: 'A current workspace is required to resolve enterprise access.', - } - } - - try { - const output = await executeCopilotPlatformContextUseCase(context, readEnterpriseContext, { - workspaceId: context.workspaceId, - }) - return { - success: true, - output, - } - } catch (error) { - return { success: false, error: messageForCopilotPlatformContextError(error) } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts index 801372da6f1..54d47e30f24 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts @@ -2,9 +2,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { executeWorkflowUseCaseMock, listUserWorkspacesMock } = vi.hoisted(() => ({ +const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ executeWorkflowUseCaseMock: vi.fn(), - listUserWorkspacesMock: vi.fn(), })) vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ @@ -13,54 +12,7 @@ vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ getErrorMessage(error, 'Workflow operation failed'), })) -vi.mock('@/lib/workspaces/utils', () => ({ - listUserWorkspaces: listUserWorkspacesMock, -})) - -import { - executeGetBlockOutputs, - executeListUserWorkspaces, -} from '@/lib/copilot/tools/handlers/workflow/queries' - -describe('executeListUserWorkspaces', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('marks the current workspace in the accessible workspace list', async () => { - listUserWorkspacesMock.mockResolvedValue([ - { workspaceId: 'workspace-1', workspaceName: 'One', role: 'owner' }, - { workspaceId: 'workspace-2', workspaceName: 'Two', role: 'read' }, - ]) - - const result = await executeListUserWorkspaces({ - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-2', - }) - - expect(listUserWorkspacesMock).toHaveBeenCalledWith('user-1') - expect(result).toEqual({ - success: true, - output: { - workspaces: [ - { - workspaceId: 'workspace-1', - workspaceName: 'One', - role: 'owner', - isCurrent: false, - }, - { - workspaceId: 'workspace-2', - workspaceName: 'Two', - role: 'read', - isCurrent: true, - }, - ], - }, - }) - }) -}) +import { executeGetBlockOutputs } from '@/lib/copilot/tools/handlers/workflow/queries' describe('executeGetBlockOutputs', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 0291fdee8fc..2129fc747bc 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' @@ -18,7 +17,6 @@ import { } from '@/lib/workflows/application/read-workflow-copilot-metadata' import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { listUserWorkspaces } from '@/lib/workspaces/utils' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' import type { GetBlockOutputsParams, @@ -30,25 +28,6 @@ import type { const logger = createLogger('WorkflowQueries') -export async function executeListUserWorkspaces( - context: ExecutionContext -): Promise { - try { - const workspaces = (await listUserWorkspaces(context.userId)).map((workspace) => ({ - ...workspace, - isCurrent: workspace.workspaceId === context.workspaceId, - })) - - return { success: true, output: { workspaces } } - } catch (error) { - logger.error('Failed to list user workspaces for Copilot', { error }) - return { - success: false, - error: messageForCopilotApplicationError(error, 'Failed to list workspaces'), - } - } -} - export async function executeGetWorkflowRunOptions( params: GetWorkflowRunOptionsParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 567143fdf1f..7c3f11c0cd5 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -513,6 +513,8 @@ const TOOL_TITLES: Record = { download_file: 'Downloading file', run_function: 'Running code', generate_api_key: 'Generating API key', + // Retired in favor of the account/ and organization/ VFS namespaces. Kept so + // a replayed transcript from before the switch still renders its rows. get_account_billing: 'Checking plan and usage', get_block_outputs: 'Reading block outputs', get_block_upstream_references: 'Tracing block inputs', diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts index 7c36001d71c..2a10feef36d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ b/apps/sim/lib/copilot/vfs/serializers.test.ts @@ -11,6 +11,11 @@ import type { BlockConfig } from '@/blocks/types' import { hostedKeyEnabledWhen } from '@/tools/hosting' import type { ToolConfig } from '@/tools/types' import { + serializeAccessControl, + serializeAccountBilling, + serializeAccountMembers, + serializeAccountWorkspace, + serializeAccountWorkspaces, serializeApiKeyIntegrations, serializeBlockSchema, serializeConnectors, @@ -19,10 +24,13 @@ import { serializeFileMeta, serializeIntegrationSchema, serializeKBMeta, + serializeOrganization, + serializeOrganizationCustomBlocks, serializeSandbox, serializeSandboxCatalog, serializeTableMeta, serializeWorkflowMeta, + serializeWorkspaceForks, } from './serializers' function hostedTool(id: string, conditional = false): ToolConfig { @@ -585,3 +593,193 @@ describe('serializeConnectors — cloneable references, never key material', () expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' }) }) }) + +describe('account and organization namespace serializers', () => { + it('references the files that own org and fork detail instead of restating them', () => { + const workspace = JSON.parse( + serializeAccountWorkspace({ + workspace: { id: 'ws-1', name: 'Elder', workspaceMode: 'standard' }, + viewer: { permission: 'admin', organizationRole: 'owner' }, + organization: { id: 'org-1', name: 'Acme' }, + forkedFrom: { id: 'ws-0', name: 'Elder (parent)' }, + entitlements: ['custom-blocks'], + }) + ) + + expect(workspace.yourPermission).toBe('admin') + expect(workspace.organization).toEqual({ + id: 'org-1', + name: 'Acme', + yourRole: 'owner', + detail: 'organization/organization.json', + }) + expect(workspace.forkedFrom.detail).toBe('organization/forks.json') + // The org record itself (plan, restrictions, members) must not be inlined — + // one relation per file is what keeps the two from disagreeing. + expect(workspace.organization.plan).toBeUndefined() + }) + + it('omits organization and fork stubs for a personal, unforked workspace', () => { + const workspace = JSON.parse( + serializeAccountWorkspace({ + workspace: { id: 'ws-1', name: 'Personal' }, + viewer: { permission: 'admin' }, + organization: null, + forkedFrom: null, + entitlements: [], + }) + ) + + expect(workspace.organization).toBeNull() + expect(workspace.forkedFrom).toBeNull() + }) + + it('withholds member emails from a non-admin viewer and says so', () => { + const members = [ + { userId: 'u-1', name: 'Ada', email: 'ada@example.com', permissionType: 'admin' }, + { + userId: 'u-2', + name: 'Grace', + email: 'grace@example.com', + permissionType: 'read', + isExternal: true, + }, + ] + + const asAdmin = JSON.parse(serializeAccountMembers(members, { includeContactDetails: true })) + expect(asAdmin.members[0].email).toBe('ada@example.com') + expect(asAdmin.note).toBeUndefined() + + const asMember = JSON.parse(serializeAccountMembers(members, { includeContactDetails: false })) + expect(asMember.members.map((m: { email?: string }) => m.email)).toEqual([undefined, undefined]) + expect(asMember.members[0].name).toBe('Ada') + expect(asMember.members[1].isExternal).toBe(true) + expect(asMember.note).toContain('admins only') + }) + + it('keeps money and usage numbers in billing.json alone', () => { + const billing = JSON.parse( + serializeAccountBilling({ + plan: 'team', + billingScope: 'organization', + organizationId: 'org-1', + usage: { + currentPeriodCost: 12.5, + limit: 100, + remaining: 87.5, + percentUsed: 12.5, + isExceeded: false, + billingPeriodEnd: new Date('2026-09-01T00:00:00.000Z'), + }, + credits: { balance: 40, scope: 'organization' }, + }) + ) + + expect(billing.plan).toBe('team') + expect(billing.billedTo).toBe('organization') + expect(billing.usage.billingPeriodEnd).toBe('2026-09-01T00:00:00.000Z') + expect(billing.credits.balance).toBe(40) + + const organization = JSON.parse( + serializeOrganization({ + organization: { id: 'org-1', relationship: 'internal', role: 'admin' }, + capabilities: { canManageOrganization: true, canManageBilling: true }, + plan: 'team', + isEnterprise: false, + }) + ) + expect(organization.usage).toBeUndefined() + expect(organization.credits).toBeUndefined() + expect(organization.note).toContain('account/billing.json') + }) + + it('describes access control as this viewer’s own binding restrictions', () => { + const accessControl = JSON.parse( + serializeAccessControl({ + entitled: true, + permissionGroup: { id: 'pg-1', name: 'Contractors', resolution: 'explicit-member' }, + restrictions: [{ key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }], + }) + ) + + expect(accessControl.governingPermissionGroup.appliedBecause).toBe('explicit-member') + expect(accessControl.activeRestrictions).toEqual([ + { key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }, + ]) + expect(accessControl.note).toContain('THIS user') + }) + + it('points custom-block provenance at the schema rather than copying fields', () => { + const blocks = JSON.parse( + serializeOrganizationCustomBlocks([ + { + type: 'acme_scorer', + name: 'Acme Scorer', + description: 'Scores a lead', + enabled: true, + workflowId: 'wf-1', + workflowName: 'Scorer', + workspaceId: 'ws-9', + workspaceName: 'Platform', + }, + { + type: 'acme_retired', + name: 'Retired', + enabled: false, + workflowId: 'wf-2', + workspaceId: null, + }, + ]) + ) + + expect(blocks.customBlocks[0].schema).toBe('components/blocks/acme_scorer.json') + expect(blocks.customBlocks[0].publishedFrom.workspaceName).toBe('Platform') + expect(blocks.customBlocks[0].inputFields).toBeUndefined() + // A disabled block cannot be added, so it gets no schema pointer. + expect(blocks.customBlocks[1].schema).toBeUndefined() + expect(blocks.customBlocks[1].publishedFrom.workspaceId).toBeUndefined() + }) + + it('summarizes fork mappings by resource type and omits them at the root', () => { + const forked = JSON.parse( + serializeWorkspaceForks({ + parent: { id: 'ws-0', name: 'Template' }, + children: [{ id: 'ws-2', name: 'Child', createdAt: new Date('2026-08-01T00:00:00.000Z') }], + resourceMappingCounts: { workflow: 3, table: 1 }, + blockMappingCount: 12, + }) + ) + expect(forked.mappedFromParent).toEqual({ resources: { workflow: 3, table: 1 }, blocks: 12 }) + expect(forked.children[0].createdAt).toBe('2026-08-01T00:00:00.000Z') + + const root = JSON.parse( + serializeWorkspaceForks({ + parent: null, + children: [], + resourceMappingCounts: {}, + blockMappingCount: 0, + }) + ) + expect(root.mappedFromParent).toBeUndefined() + }) + + it('marks the current workspace and never implies the others are readable', () => { + const roster = JSON.parse( + serializeAccountWorkspaces([ + { id: 'ws-1', name: 'Elder', role: 'admin', isCurrent: true, organizationId: 'org-1' }, + { + id: 'ws-2', + name: 'Other', + role: 'read', + isCurrent: false, + forkedFromWorkspaceId: 'ws-1', + }, + ]) + ) + + expect(roster.workspaces[0].isCurrent).toBe(true) + expect(roster.workspaces[1].isCurrent).toBeUndefined() + expect(roster.workspaces[1].forkedFromWorkspaceId).toBe('ws-1') + expect(roster.note).toContain('isCurrent') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 032ff549b11..00637955749 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1326,3 +1326,307 @@ export function serializeTableViews( 2 ) } + +/** + * `account/workspace.json` — the current workspace as this viewer sees it: + * identity, the viewer's effective permission, org linkage, and fork parentage. + * + * Owns the current-workspace record. Org detail lives in + * `organization/organization.json` and fork topology in + * `organization/forks.json`; both are referenced here by id-and-name stub only, + * so a fact can never disagree with the file that owns it. + */ +export function serializeAccountWorkspace(input: { + workspace: { id: string; name: string; workspaceMode?: string | null } + viewer: { permission: string | null; organizationRole?: string | null } + organization: { id: string; name?: string | null } | null + forkedFrom: { id: string; name: string } | null + entitlements: string[] +}): string { + return JSON.stringify( + { + id: input.workspace.id, + name: input.workspace.name, + ...(input.workspace.workspaceMode ? { mode: input.workspace.workspaceMode } : {}), + yourPermission: input.viewer.permission, + organization: input.organization + ? { + id: input.organization.id, + ...(input.organization.name ? { name: input.organization.name } : {}), + ...(input.viewer.organizationRole ? { yourRole: input.viewer.organizationRole } : {}), + detail: 'organization/organization.json', + } + : null, + forkedFrom: input.forkedFrom + ? { + id: input.forkedFrom.id, + name: input.forkedFrom.name, + detail: 'organization/forks.json', + } + : null, + entitlements: input.entitlements, + note: 'Read-only. Your accessible workspaces are in account/workspaces.json; members in account/members.json; plan and usage in account/billing.json.', + }, + null, + 2 + ) +} + +/** + * `account/workspaces.json` — every workspace the viewer can reach, as stubs. + * + * Deliberately a roster, not a set of records: id, name, the viewer's role, and + * org/fork parentage by id. Anything richer about the *current* workspace is in + * `account/workspace.json`; other workspaces are not readable from here at all. + */ +export function serializeAccountWorkspaces( + workspaces: Array<{ + id: string + name: string + role: string + organizationId?: string | null + forkedFromWorkspaceId?: string | null + isCurrent: boolean + }> +): string { + return JSON.stringify( + { + workspaces: workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + yourRole: workspace.role, + ...(workspace.organizationId ? { organizationId: workspace.organizationId } : {}), + ...(workspace.forkedFromWorkspaceId + ? { forkedFromWorkspaceId: workspace.forkedFromWorkspaceId } + : {}), + ...(workspace.isCurrent ? { isCurrent: true } : {}), + })), + note: 'Only the current workspace (isCurrent) is mounted in this VFS — the others are listed so you can name them, not read them. Switching workspaces is the user’s action, not yours.', + }, + null, + 2 + ) +} + +/** + * `account/members.json` — who is in the current workspace, with roles. + * + * `includeContactDetails` is the viewer's own admin bit: emails and pending + * invitations are the same privilege as the members settings page, so a + * non-admin viewer gets names and roles without contact details. + */ +export function serializeAccountMembers( + members: Array<{ + userId: string + name: string | null + email: string | null + permissionType: string + isExternal?: boolean + roleSource?: string + }>, + options: { includeContactDetails: boolean } +): string { + return JSON.stringify( + { + members: members.map((member) => ({ + userId: member.userId, + name: member.name ?? null, + ...(options.includeContactDetails && member.email ? { email: member.email } : {}), + role: member.permissionType, + ...(member.isExternal ? { isExternal: true } : {}), + ...(member.roleSource && member.roleSource !== 'explicit' + ? { roleSource: member.roleSource } + : {}), + })), + total: members.length, + ...(options.includeContactDetails + ? {} + : { note: 'Email addresses are shown to workspace admins only.' }), + }, + null, + 2 + ) +} + +/** + * `account/billing.json` — the acting user's live plan, usage, and credits. + * + * The only file that carries money and usage numbers; `organization.json` links + * here rather than repeating them. Read at request time, so the numbers are + * current rather than as-of-materialization. + */ +export function serializeAccountBilling(snapshot: { + plan: string + billingScope: 'user' | 'organization' + organizationId: string | null + usage: { + currentPeriodCost: number + limit: number + remaining: number + percentUsed: number + isExceeded: boolean + billingPeriodEnd: Date | string | null + } + credits: { balance: number; scope: 'user' | 'organization' } +}): string { + const periodEnd = snapshot.usage.billingPeriodEnd + return JSON.stringify( + { + plan: snapshot.plan, + billedTo: snapshot.billingScope, + ...(snapshot.organizationId ? { organizationId: snapshot.organizationId } : {}), + usage: { + currentPeriodCost: snapshot.usage.currentPeriodCost, + limit: snapshot.usage.limit, + remaining: snapshot.usage.remaining, + percentUsed: snapshot.usage.percentUsed, + isExceeded: snapshot.usage.isExceeded, + billingPeriodEnd: periodEnd instanceof Date ? periodEnd.toISOString() : periodEnd, + }, + credits: { balance: snapshot.credits.balance, scope: snapshot.credits.scope }, + note: 'Live values for the acting user, read at access time. What the plan tiers and credits mean is a documentation question, not a value in this file.', + }, + null, + 2 + ) +} + +/** + * `organization/organization.json` — the org that hosts this workspace and the + * viewer's standing in it. Owns the organization record; plan economics stay in + * `account/billing.json`. + */ +export function serializeOrganization(input: { + organization: { id: string; relationship: string; role: string | null } + capabilities: { canManageOrganization: boolean; canManageBilling: boolean } + plan: string | null + isEnterprise: boolean +}): string { + return JSON.stringify( + { + id: input.organization.id, + yourRelationship: input.organization.relationship, + yourRole: input.organization.role, + canManageOrganization: input.capabilities.canManageOrganization, + canManageBilling: input.capabilities.canManageBilling, + ...(input.plan ? { plan: input.plan } : {}), + isEnterprise: input.isEnterprise, + note: 'Plan usage and credits are in account/billing.json. Your effective restrictions are in organization/access-control.json.', + }, + null, + 2 + ) +} + +/** + * `organization/access-control.json` — who can see and do what, from the + * viewer's vantage: the permission group governing them and the restrictions it + * actually imposes. + * + * Scoped to the viewer on purpose. The full group roster is an org-admin + * settings surface, not workspace context. + */ +export function serializeAccessControl(input: { + entitled: boolean + permissionGroup: { id: string; name: string; resolution: string } | null + restrictions: Array<{ key: string; description: string }> +}): string { + return JSON.stringify( + { + entitled: input.entitled, + governingPermissionGroup: input.permissionGroup + ? { + id: input.permissionGroup.id, + name: input.permissionGroup.name, + appliedBecause: input.permissionGroup.resolution, + } + : null, + activeRestrictions: input.restrictions.map((restriction) => ({ + key: restriction.key, + description: restriction.description, + })), + note: 'These restrictions are enforced server-side on every action, so a blocked request fails no matter how it is phrased. They describe THIS user; other members may be governed by different groups.', + }, + null, + 2 + ) +} + +/** + * `organization/custom-blocks.json` — provenance for org-published blocks: who + * published each one and from which workflow. + * + * The block's callable schema stays at `components/blocks/{type}.json`; this + * file points at it rather than restating fields. + */ +export function serializeOrganizationCustomBlocks( + blocks: Array<{ + type: string + name: string + description?: string | null + enabled: boolean + workflowId: string + workflowName?: string | null + workspaceId: string | null + workspaceName?: string | null + }> +): string { + return JSON.stringify( + { + customBlocks: blocks.map((block) => ({ + type: block.type, + name: block.name, + ...(block.description ? { description: block.description } : {}), + enabled: block.enabled, + publishedFrom: { + workflowId: block.workflowId, + ...(block.workflowName ? { workflowName: block.workflowName } : {}), + ...(block.workspaceId ? { workspaceId: block.workspaceId } : {}), + ...(block.workspaceName ? { workspaceName: block.workspaceName } : {}), + }, + ...(block.enabled ? { schema: `components/blocks/${block.type}.json` } : {}), + })), + note: 'Org-wide blocks published from a deployed workflow. Configure one from its schema under components/blocks/; a disabled block cannot be added to a workflow.', + }, + null, + 2 + ) +} + +/** + * `organization/forks.json` — this workspace's place in the fork tree plus the + * parent/child resource and block mappings. + * + * Owns fork topology; rosters elsewhere carry only `forkedFromWorkspaceId`. + * Mapping counts are summarized per resource type — the raw id pairs are an + * implementation detail of promote/rollback, not workspace context. + */ +export function serializeWorkspaceForks(input: { + parent: { id: string; name: string } | null + children: Array<{ id: string; name: string; createdAt: Date | string }> + resourceMappingCounts: Record + blockMappingCount: number +}): string { + return JSON.stringify( + { + parent: input.parent, + children: input.children.map((child) => ({ + id: child.id, + name: child.name, + createdAt: + child.createdAt instanceof Date ? child.createdAt.toISOString() : child.createdAt, + })), + ...(input.parent + ? { + mappedFromParent: { + resources: input.resourceMappingCounts, + blocks: input.blockMappingCount, + }, + } + : {}), + note: 'A forked workspace keeps a mapping back to the resources it was copied from, which is what promote and rollback follow. Forking, promoting, and rolling back are workspace-admin actions in the UI — you cannot perform them.', + }, + null, + 2 + ) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e774db56099..9286097081f 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -16,12 +16,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' +import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' import { buildWorkspaceContextMd, buildWorkspaceMd, type WorkspaceMdData, } from '@/lib/copilot/chat/workspace-context' +import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { @@ -67,6 +69,11 @@ import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import type { DeploymentData, VfsServiceAccountAuth } from '@/lib/copilot/vfs/serializers' import { describeServiceAccountForOAuthProvider, + serializeAccessControl, + serializeAccountBilling, + serializeAccountMembers, + serializeAccountWorkspace, + serializeAccountWorkspaces, serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, @@ -83,6 +90,8 @@ import { serializeIntegrationSchema, serializeKBMeta, serializeMcpServer, + serializeOrganization, + serializeOrganizationCustomBlocks, serializeRecentExecutions, serializeSandbox, serializeSandboxCatalog, @@ -93,6 +102,7 @@ import { serializeTriggerSchema, serializeVersions, serializeWorkflowMeta, + serializeWorkspaceForks, } from '@/lib/copilot/vfs/serializers' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { @@ -126,6 +136,7 @@ import { } from '@/lib/knowledge/application/knowledge-bases' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { listTables } from '@/lib/table/service' import { @@ -152,18 +163,27 @@ import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-wo import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { assertActiveWorkspaceAccess, getUsersWithPermissions, getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' +import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' +import { + getUserPermissionConfig, + resolveVerifiedUserAccessControlContext, +} from '@/ee/access-control/utils/permission-check' +import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' +import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' +import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store' import type { ToolConfig } from '@/tools/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' @@ -590,6 +610,14 @@ function getStaticComponentFiles(): Map { * custom-tools/{name}.json * agent/sandboxes/README.md * agent/sandboxes/{name}.json + * account/workspace.json (this workspace + your role; always present) + * account/workspaces.json (every workspace you can reach) + * account/members.json (workspace members; emails admin-only) + * account/billing.json (plan/usage/credits; lazy, read fresh) + * organization/organization.json (org standing; only when org-hosted) + * organization/access-control.json (your governing group + restrictions) + * organization/custom-blocks.json (org-published block provenance) + * organization/forks.json (fork topology; workspace admins only) * environment/credentials.json * environment/api-keys.json * environment/variables.json @@ -827,6 +855,13 @@ export class WorkspaceVFS { 'sandbox_entitlement', hasWorkspaceSandboxAccess(workspaceId) ) + // Shared with the account/ and organization/ namespaces so the + // roster and host context are each read once per materialization. + const membersPromise = timed('members', getUsersWithPermissions(workspaceId)) + const hostContextPromise = timed( + 'host_context', + getWorkspaceHostContextForViewer(workspaceId, userId).catch(() => null) + ) const [ wfSummary, kbSummary, @@ -868,10 +903,19 @@ export class WorkspaceVFS { ) ), timed('workspace_row', getWorkspaceWithOwner(workspaceId)), - timed('members', getUsersWithPermissions(workspaceId)), + membersPromise, permissionConfigPromise, sandboxEntitlementPromise, ]) + + // account/ and organization/ describe the viewer's standing rather + // than workspace resources, so they are materialized after the + // resource pass and contribute nothing to WORKSPACE.md. + const hostContext = await hostContextPromise + await Promise.all([ + timed('account', this.materializeAccount(workspaceId, userId, hostContext, members)), + timed('organization', this.materializeOrganization(workspaceId, userId, hostContext)), + ]) const workspaceMdData: WorkspaceMdData = { workspace: wsRow, members, @@ -2332,6 +2376,218 @@ export class WorkspaceVFS { } } + /** + * Materialize `account/` — the acting user's vantage: this workspace and + * their role in it, the workspaces they can reach, who else is here, and + * their live plan. + * + * Read-only and always mounted. `billing.json` is registered lazily because + * usage ticks between requests: materializing it would freeze the numbers at + * snapshot time and pay for a billing read on every turn that never asks. + * Membership reuses the roster already loaded for WORKSPACE.md rather than + * issuing a second query. + */ + private async materializeAccount( + workspaceId: string, + userId: string, + hostContext: Awaited>, + members: Awaited> + ): Promise { + try { + const [rows, entitlements] = await Promise.all([ + listAccessibleWorkspaceRowsForUser(userId).catch(() => []), + computeWorkspaceEntitlements(workspaceId, userId).catch(() => [] as string[]), + ]) + + const current = rows.find((row) => row.workspace.id === workspaceId) + const parentId = current?.workspace.forkedFromWorkspaceId ?? null + // Name the parent only when the viewer can reach it; otherwise the id + // stands alone rather than leaking a workspace name they cannot open. + const parentRow = parentId ? rows.find((row) => row.workspace.id === parentId) : undefined + const isAdmin = hostContext?.viewer.permission === 'admin' + + this.files.set( + 'account/workspace.json', + serializeAccountWorkspace({ + workspace: { + id: workspaceId, + name: hostContext?.workspace.name ?? current?.workspace.name ?? '', + workspaceMode: hostContext?.workspace.workspaceMode ?? null, + }, + viewer: { + permission: hostContext?.viewer.permission ?? current?.permissionType ?? null, + organizationRole: hostContext?.viewer.organizationRole ?? null, + }, + organization: hostContext?.hostOrganizationId + ? { id: hostContext.hostOrganizationId } + : null, + forkedFrom: parentId + ? { id: parentId, name: parentRow?.workspace.name ?? parentId } + : null, + entitlements, + }) + ) + + this.files.set( + 'account/workspaces.json', + serializeAccountWorkspaces( + rows.map((row) => ({ + id: row.workspace.id, + name: row.workspace.name, + role: row.permissionType, + organizationId: row.workspace.organizationId, + forkedFromWorkspaceId: row.workspace.forkedFromWorkspaceId, + isCurrent: row.workspace.id === workspaceId, + })) + ) + ) + + this.files.set( + 'account/members.json', + serializeAccountMembers(members, { includeContactDetails: isAdmin }) + ) + + this.registerLazy('account/billing.json', async () => { + try { + return serializeAccountBilling(await getAccountBillingSnapshot(userId)) + } catch (err) { + logger.warn('Failed to load account billing', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + } catch (err) { + logger.warn('Failed to materialize account namespace', { + workspaceId, + error: toError(err).message, + }) + } + } + + /** + * Materialize `organization/` — org standing, the access-control rules that + * actually bind this viewer, org-published block provenance, and fork + * topology. + * + * The namespace exists only when the workspace belongs to an organization, so + * its absence is itself the answer for a personal workspace. Fork detail is + * mounted only for a workspace admin of a forking-enabled org, matching the + * gate the fork routes apply. + */ + private async materializeOrganization( + workspaceId: string, + userId: string, + hostContext: Awaited> + ): Promise { + const organizationId = hostContext?.hostOrganizationId + if (!hostContext || !organizationId) return + + try { + this.files.set( + 'organization/organization.json', + serializeOrganization({ + organization: { + id: organizationId, + relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', + role: hostContext.viewer.organizationRole ?? null, + }, + capabilities: { + canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, + canManageBilling: hostContext.viewer.isHostOrganizationAdmin, + }, + plan: hostContext.ownerBilling.plan, + isEnterprise: hostContext.ownerBilling.isEnterprise, + }) + ) + + this.registerLazy('organization/access-control.json', async () => { + try { + const accessControl = await resolveVerifiedUserAccessControlContext( + userId, + workspaceId, + organizationId + ) + return serializeAccessControl({ + entitled: accessControl.entitled, + permissionGroup: accessControl.permissionGroup, + restrictions: getActivePermissionGroupRestrictions(accessControl.config), + }) + } catch (err) { + logger.warn('Failed to load access control context', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + + this.registerLazy('organization/custom-blocks.json', async () => { + try { + const blocks = await listCustomBlocksWithInputsForWorkspace(workspaceId) + if (blocks.length === 0) return null + return serializeOrganizationCustomBlocks(blocks) + } catch (err) { + logger.warn('Failed to load org custom blocks', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + + if (hostContext.viewer.permission !== 'admin') return + if (!(await isForkingAvailableForWorkspace(organizationId, userId).catch(() => false))) return + + this.registerLazy('organization/forks.json', async () => { + try { + const [parent, children] = await Promise.all([ + getForkParent(workspaceId), + getForkChildren(workspaceId), + ]) + if (!parent && children.length === 0) return null + + const resourceMappingCounts: Record = {} + let blockMappingCount = 0 + if (parent) { + const [resourceRows, blockMap] = await Promise.all([ + getEdgeMappingRows(db, workspaceId), + loadForkBlockMap(db, workspaceId), + ]) + for (const row of resourceRows) { + resourceMappingCounts[row.resourceType] = + (resourceMappingCounts[row.resourceType] ?? 0) + 1 + } + blockMappingCount = blockMap.parentToChild.size + } + + return serializeWorkspaceForks({ + parent: parent ? { id: parent.id, name: parent.name } : null, + children: children.map((child) => ({ + id: child.id, + name: child.name, + createdAt: child.createdAt, + })), + resourceMappingCounts, + blockMappingCount, + }) + } catch (err) { + logger.warn('Failed to load fork topology', { + workspaceId, + error: toError(err).message, + }) + return null + } + }) + } catch (err) { + logger.warn('Failed to materialize organization namespace', { + workspaceId, + error: toError(err).message, + }) + } + } + /** * Materialize external MCP server connections using the mcpServers table. */ From 370896d5be38c9cc1cfd2b1beef47b5c91b27ae9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:10:38 -0700 Subject: [PATCH 051/135] Fix insert_text refusing an editable field focused inside a frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describeFocusedEditable descended shadow roots but not frames, while activeElementReadback descends both. Focus inside a same-origin frame therefore surfaced to the first as the FRAME element — not an input, not contentEditable, not a canvas, no textbox role — so it fell through to 'not-editable' and insert_text refused a field that press_key had just typed a character into. Two functions answering 'what is focused' with different answers is the bug; the descent loops now match exactly. The refusal also names what actually held focus (tag, role, contenteditable). A bare 'not-editable' gave the agent nothing to act on, so it guessed at the cause — a real run spent twenty rounds on the wrong theory and had to be stopped by the user. --- apps/desktop/src/main/browser-agent/driver.ts | 18 ++++++++++- .../main/browser-agent/page-functions.test.ts | 32 +++++++++++++++++++ .../src/main/browser-agent/page-functions.ts | 31 +++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 7f659bdebce..bbd3e650660 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -3224,10 +3224,26 @@ async function executeToolInner( ) if (!isRecordLike(focusState) || focusState.editable !== true) { const reason = isRecordLike(focusState) ? String(focusState.reason || '') : '' + // Name the element that actually held focus. Without it the agent + // cannot tell "I focused the wrong thing" from "this tool cannot type + // here", and it retries variations of the same failing approach. + const focused = isRecordLike(focusState) + ? [ + focusState.focusedTag ? `<${String(focusState.focusedTag)}>` : '', + focusState.focusedRole ? `role="${String(focusState.focusedRole)}"` : '', + focusState.contentEditable && focusState.contentEditable !== 'unset' + ? `contenteditable="${String(focusState.contentEditable)}"` + : '', + ] + .filter(Boolean) + .join(' ') + : '' throw new ToolError( reason === 'none' ? 'No element is focused. Click the field first (browser_click or browser_click_at), then insert text.' - : `The focused element does not accept text${reason ? ` (${reason})` : ''}. Focus an editable field first.` + : `The focused element does not accept text${reason ? ` (${reason})` : ''}${ + focused ? `; focus is on ${focused}` : '' + }. Click the field you want to type into, then insert text.` ) } const beforePage = await pageActionState(target, true) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index a8f6b8816e0..5084f208e62 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -1428,6 +1428,38 @@ describe('describeFocusedEditable', () => { expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' }) }) + // The bug this pins: focus inside a same-origin frame surfaces on the outer + // document as the FRAME element, which is not an input, not contentEditable, + // not a canvas, and carries no textbox role — so a composer that press-key + // typed into fine was reported `not-editable` and insert_text refused. The + // descent here must match activeElementReadback's exactly. + it('descends a same-origin frame to the editable that really holds focus', () => { + document.body.innerHTML = '' + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '
composer
' + const composer = inner.querySelector('div') as HTMLElement + Object.defineProperty(composer, 'isContentEditable', { value: true, configurable: true }) + setActiveElement(inner, composer) + setActiveElement(document, frame) + + expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' }) + }) + + it('names the focused element when it refuses, so the agent can recover', () => { + document.body.innerHTML = '
Send
' + setActiveElement(document, document.querySelector('div')) + + expect(describeFocusedEditable()).toEqual({ + editable: false, + reason: 'not-editable', + focusedTag: 'div', + focusedRole: 'button', + contentEditable: 'unset', + }) + }) + it('reports a writable input as insertable', () => { document.body.innerHTML = '' setActiveElement(document, document.querySelector('input')) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 33af0d6f964..2f5e9b0d5bd 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -2782,6 +2782,13 @@ export function describePointTarget(x: number, y: number): unknown { * activeElementSecrecy before any insertion. */ export function describeFocusedEditable(): unknown { + // Descend shadow roots AND same-origin frames, matching activeElementReadback + // exactly. Focus inside a frame surfaces on the outer document as the FRAME + // element, which is not an input, not contentEditable, not a canvas and has no + // textbox role — so stopping here reported a perfectly writable field as + // `not-editable`, while press-key (which does descend) typed into it fine. + // Any divergence between these two loops is a tool that refuses what its + // sibling accepts on the same page state. let active = document.activeElement as HTMLElement | null for (let depth = 0; active && depth < 10; depth++) { const shadow = active.shadowRoot @@ -2789,6 +2796,19 @@ export function describeFocusedEditable(): unknown { active = shadow.activeElement as HTMLElement continue } + const activeTag = String(active.tagName || '').toUpperCase() + if (activeTag === 'IFRAME' || activeTag === 'FRAME') { + try { + const inner = (active as HTMLIFrameElement).contentDocument + if (inner?.activeElement && inner.activeElement !== inner.body) { + active = inner.activeElement as HTMLElement + continue + } + } catch { + // Cross-origin frame — not inspectable. The caller refuses separately on + // opaque secrecy, so report the frame itself rather than guessing. + } + } break } if (!active || active === document.body) return { editable: false, reason: 'none' } @@ -2826,5 +2846,14 @@ export function describeFocusedEditable(): unknown { if (tag === 'CANVAS' || active.getAttribute('role') === 'textbox') { return { editable: true, kind: tag === 'CANVAS' ? 'canvas' : 'textbox-role' } } - return { editable: false, reason: 'not-editable' } + // Describe what actually held focus. A bare "not-editable" tells the agent + // nothing it can act on, so it guesses at the cause and burns rounds on the + // wrong recovery; naming the element lets it click the real field instead. + return { + editable: false, + reason: 'not-editable', + focusedTag: tag.toLowerCase(), + ...(active.getAttribute('role') ? { focusedRole: active.getAttribute('role') } : {}), + contentEditable: String(active.getAttribute('contenteditable') ?? 'unset'), + } } From da39cf02fdb1f0ac36cf884bc5561a83c36a71ae Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:15:32 -0700 Subject: [PATCH 052/135] Keep retired browser takeover renderable in history The tool is gone from the catalog, so its generated constant went with it and every path that referenced it stopped compiling. Deleting those paths instead would have silently downgraded every past transcript containing a takeover card to a generic tool row, and dropped the no-timeout budget that an in-flight takeover still needs while a rolling deploy finishes. retired-tools.ts gives the literal a documented home that says what it is and why it survives its tool. --- .../components/agent-group/agent-group.tsx | 4 +-- .../components/agent-group/tool-call-item.tsx | 4 +-- .../mothership-chat/mothership-chat.tsx | 35 +++++++++++++++++-- .../sim/lib/copilot/chat/persisted-message.ts | 4 +-- .../lib/copilot/generated/tool-catalog-v1.ts | 28 --------------- .../lib/copilot/generated/tool-schemas-v1.ts | 20 ----------- apps/sim/lib/copilot/request/handlers/tool.ts | 4 +-- .../sim/lib/copilot/request/tools/executor.ts | 4 +-- apps/sim/lib/copilot/tools/retired-tools.ts | 19 ++++++++++ 9 files changed, 61 insertions(+), 61 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/retired-tools.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 00b3a81fe37..e72b469184b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -4,7 +4,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn' import { ShimmerText } from '@/components/ui' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { useSmoothText } from '@/hooks/use-smooth-text' import { type ToolCallData, ToolCallStatus } from '../../../../types' import { getAgentIcon, isToolDone } from '../../utils' @@ -63,7 +63,7 @@ function getActiveBrowserTakeover(items: AgentGroupItem[]): ActiveBrowserTakeove const item = items[index] if (item.type !== 'tool') continue if ( - item.data.toolName === BrowserRequestTakeover.id && + item.data.toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && item.data.status === ToolCallStatus.executing ) { const reason = item.data.params?.reason diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 994ee466628..ccda6ce73ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react' import { isPlainRecord } from '@sim/utils/object' import { ShimmerText } from '@/components/ui' import { - BrowserRequestTakeover, CallIntegrationTool, PrepareFileEdit, Read as ReadTool, @@ -10,6 +9,7 @@ import { Wait as WaitTool, } from '@/lib/copilot/generated/tool-catalog-v1' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display' import { getBareIconStyle } from '@/blocks/brand-icon-style' @@ -168,7 +168,7 @@ export function ToolCallItem({ const displayState = resolveToolDisplayState(status) const isExecuting = displayState === 'spinner' - const isBrowserTakeover = toolName === BrowserRequestTakeover.id + const isBrowserTakeover = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const isCountingDown = toolName === WaitTool.id && isExecuting const elapsedMs = useElapsedMs(isCountingDown, startedAt) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 8d4bccd9c9f..84025228547 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -664,6 +664,28 @@ export function MothershipChat({ handleEditQueued(tail.id) }, [handleEditQueued]) + /** + * A drag-selection that overshoots a message's last line crosses the row + * wrappers' block boundaries, which the clipboard serializer renders as + * trailing newlines — every copied response pasted with blank lines + * appended. Rewrite the plain-text flavor trimmed; the rich flavor is + * re-serialized from the selection so formatted pastes keep working. + */ + const handleCopy = useCallback((event: React.ClipboardEvent) => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !event.clipboardData) return + const text = selection.toString() + const trimmed = text.replace(/\s+$/, '') + if (trimmed === text) return + event.preventDefault() + event.clipboardData.setData('text/plain', trimmed) + const html = document.createElement('div') + for (let i = 0; i < selection.rangeCount; i++) { + html.appendChild(selection.getRangeAt(i).cloneContents()) + } + event.clipboardData.setData('text/html', html.innerHTML) + }, []) + /** * Land at the most recent message once per chat — on open and when switching * chats. The ref tracks which `chatId` we last scrolled for (seeded with @@ -696,7 +718,7 @@ export function MothershipChat({ onWorkspaceResourceSelect={onWorkspaceResourceSelect} >
-
+
{isLoading && !hasMessages ? ( ) : ( @@ -714,8 +736,15 @@ export function MothershipChat({ key={virtualItem.key} data-index={index} ref={virtualizer.measureElement} - className='absolute top-0 left-0 w-full' - style={{ transform: `translateY(${virtualItem.start}px)` }} + /* Positioned with a real `top`, NOT `top-0` + translateY: + text selection maps a drag's start point to a text + position via the rows' LAYOUT boxes, and with every row + laid out at y=0 a drag starting in the gutter anchors in + the wrong row — selections ran upward from a downward + drag. Transforms move paint and hit-testing but not the + layout box that mapping falls back to. */ + className='absolute left-0 w-full' + style={{ top: virtualItem.start }} > {msg.role === 'user' ? ( interactionPairing.hiddenUserByIndex[index] ? null : ( diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index dac5cbd67aa..fb8a30ba5bb 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -15,12 +15,12 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1' import type { ContentBlock, LocalToolCallStatus, OrchestratorResult, } from '@/lib/copilot/request/types' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted' @@ -152,7 +152,7 @@ export function stripToolResultOutput(message: PersistedMessage): PersistedMessa if (!toolCall || !result || typeof result !== 'object' || !('output' in result)) return block const output = result.output const userInstruction = - toolCall.name === BrowserRequestTakeover.id && isPlainRecord(output) + toolCall.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && isPlainRecord(output) ? output.userInstruction : undefined const normalizedInstruction = typeof userInstruction === 'string' ? userInstruction.trim() : '' diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 8164a205757..a52c890090e 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -26,7 +26,6 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' - | 'browser_request_takeover' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' @@ -154,7 +153,6 @@ export interface ToolCatalogEntry { | 'browser_open_url' | 'browser_press_key' | 'browser_read_text' - | 'browser_request_takeover' | 'browser_screenshot' | 'browser_scroll' | 'browser_select_option' @@ -1223,31 +1221,6 @@ export const BrowserReadText: ToolCatalogEntry = { clientExecutable: true, } -export const BrowserRequestTakeover: ToolCatalogEntry = { - id: 'browser_request_takeover', - name: 'browser_request_takeover', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - clientExecutable: true, -} - export const BrowserScreenshot: ToolCatalogEntry = { id: 'browser_screenshot', name: 'browser_screenshot', @@ -7186,7 +7159,6 @@ export const TOOL_CATALOG: Record = { [BrowserOpenUrl.id]: BrowserOpenUrl, [BrowserPressKey.id]: BrowserPressKey, [BrowserReadText.id]: BrowserReadText, - [BrowserRequestTakeover.id]: BrowserRequestTakeover, [BrowserScreenshot.id]: BrowserScreenshot, [BrowserScroll.id]: BrowserScroll, [BrowserSelectOption.id]: BrowserSelectOption, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index b4339923a66..f98dd73b488 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -1103,26 +1103,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - browser_request_takeover: { - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - resultSchema: undefined, - }, browser_screenshot: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 31974d1b489..371fa28894f 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -15,7 +15,6 @@ import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, } from '@/lib/copilot/generated/mothership-stream-v1' -import { BrowserRequestTakeover } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' @@ -46,6 +45,7 @@ import type { import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' @@ -807,7 +807,7 @@ async function dispatchToolExecution( */ function waitForClientExecution(): Promise { toolCall.status = 'executing' - const waitsForHuman = toolName === BrowserRequestTakeover.id + const waitsForHuman = toolName === RETIRED_BROWSER_REQUEST_TAKEOVER_ID const timeoutMs = waitsForHuman ? null : options.timeout || STREAM_TIMEOUT_MS return withCopilotSpan( TraceSpan.CopilotToolWaitForClientResult, diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index 7a181bfa4f0..127267555dc 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -21,7 +21,6 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import { ApplyFileEdit, - BrowserRequestTakeover, CreateEmptyFile, CreateWorkflow, DeployAsApi, @@ -78,6 +77,7 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -259,7 +259,7 @@ export function toolWatchdogTimeoutMs(toolName: string | undefined): number { export function pendingToolWaitBudgetMs( toolCall: Pick | undefined ): number | null { - if (toolCall?.name === BrowserRequestTakeover.id && toolCall.status === 'executing') { + if (toolCall?.name === RETIRED_BROWSER_REQUEST_TAKEOVER_ID && toolCall?.status === 'executing') { return null } if (toolCall?.status === 'awaiting_approval') return TOOL_WATCHDOG_LONG_RUNNING_MS diff --git a/apps/sim/lib/copilot/tools/retired-tools.ts b/apps/sim/lib/copilot/tools/retired-tools.ts new file mode 100644 index 00000000000..97790d4c434 --- /dev/null +++ b/apps/sim/lib/copilot/tools/retired-tools.ts @@ -0,0 +1,19 @@ +/** + * Ids of tools that no longer exist in the catalog but still appear in + * persisted chat history. + * + * A retired tool stops being generated into `tool-catalog-v1`, so any render + * path that referenced its generated constant would fail to compile — and + * deleting those paths instead would silently downgrade every historical + * transcript that contains one. These literals keep replay intact without + * implying the tool is callable: nothing dispatches them, and no agent is + * offered them. + */ + +/** + * Retired with the browser takeover flow. The browser panel is live and shared + * — the user can act in it whenever they want — so there was never anything to + * hand over, and the agent no longer has a concept of taking or returning + * control. Chats from before the removal still contain takeover cards. + */ +export const RETIRED_BROWSER_REQUEST_TAKEOVER_ID = 'browser_request_takeover' From 7b3a804c9fc080353ec64e134dd9a392dbe5ff6b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:17:57 -0700 Subject: [PATCH 053/135] Follow the agent into a tab it opened to work in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_open_tab created the page with activate: false, so the agent worked in a tab the user could not see while the panel sat on a page where nothing was happening. The panel now follows a tab the agent deliberately opened. Scoped to that tool only. A page spawning its own tab (popup, target=_blank) is the site grabbing the view rather than the agent choosing a workspace, and stays in the background as before — two existing tests pin that and caught the first version of this change, which moved both. A tab the user claimed still wins over both: the work starts in the background instead of pulling the page out from under them mid-read. --- apps/desktop/src/main/browser-agent/driver.ts | 3 ++- .../src/main/browser-agent/session.test.ts | 15 ++++++++++++++ .../desktop/src/main/browser-agent/session.ts | 20 ++++++++++++++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index bbd3e650660..5d4b199aa91 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -1910,7 +1910,8 @@ async function executeToolInner( } } assertCurrentExecution() - const tab = session.addAutomationTab() + // The agent chose to open this page to work in, so the panel follows it. + const tab = session.addAutomationTab({ reveal: true }) const contents = tab.view.webContents if (url) { assertCurrentExecution() diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index f846cb90749..4c6a0247c50 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1715,6 +1715,21 @@ describe('browser-agent session', () => { expect(contents.loadURL).not.toHaveBeenCalled() }) + it('brings an agent-opened working tab into view, unless the user claimed the visible one', () => { + // browser_open_tab is the agent choosing a page to work in — the panel + // follows it so the work is visible, which a popup deliberately does not. + const first = session.ensureTab() + const working = session.addAutomationTab({ reveal: true }) + expect(session.activeTab()).toBe(working) + expect(working.id).not.toBe(first.id) + + // Once the user claims what they are looking at, the next agent tab opens + // behind it rather than yanking the page out from under them. + session.claimActiveTabForUser() + const background = session.addAutomationTab({ reveal: true }) + expect(session.activeTab()).not.toBe(background) + }) + it('keeps agent popups in the background and context-menu links user-owned', () => { const onTabCreated = vi.fn() session = freshSession(win, { onTabCreated }) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 97ee4e547eb..5b77f6939ed 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -1590,10 +1590,24 @@ export function addTab(): AgentTab { return addTabInternal() } -/** Opens a tab for agent work without replacing the page the user is viewing. */ -export function addAutomationTab(): AgentTab { +/** + * Opens a tab for agent work. + * + * `reveal` is for the agent deliberately opening a page to work in + * (`browser_open_tab`): the panel follows it, so the user watches the work + * instead of staring at a page where nothing is happening. It is NOT set when a + * page spawns a tab on its own (popups, `target="_blank"`) — that is the site + * grabbing the view, not the agent choosing a workspace. + * + * Even with `reveal`, a tab the user claimed themselves wins: pulling the view + * off the page they are reading is the same interruption as a window stealing + * focus mid-sentence. The work still starts, just in the background, and the + * tab strip shows it arriving. + */ +export function addAutomationTab({ reveal = false }: { reveal?: boolean } = {}): AgentTab { restoreBrowserSession() - const tab = addTabInternal({ activate: false, notify: false }) + const followTheWork = reveal && !currentScope.visibleTabUserSelected + const tab = addTabInternal({ activate: followTheWork, notify: false }) currentScope.automationTabId = tab.id applyActiveTabThrottling() persistBrowserSession() From d8b604beda39e416024c1909b22316864ccac738 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 15 Aug 2026 17:22:47 -0700 Subject: [PATCH 054/135] Make the browser tools agree with each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the module found the frame-descent bug was one instance of a pattern: six independent definitions of 'is this editable' and seven of 'what is focused', disagreeing with each other. A tool refusing what its sibling accepts on identical page state is invisible at runtime — the agent follows a snapshot that says one thing into a tool that says another. - browser_type now accepts role="textbox" like browser_insert_text does. The snapshot advertises those elements as [textbox] with a ref, so refusing them meant rejecting exactly what the outline told the model to type into. Both the native and synthetic paths, and their descendant scans. - pressKeyOnPage descends shadow roots and frames like every other focus reader. It was dispatching synthetic keys at the shadow host or