diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts deleted file mode 100644 index 894e543c3b2..00000000000 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @vitest-environment node - */ -import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockOAuth2LinkAccount, - mockCheckWorkspaceAccess, - mockGetCredentialActorContext, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockOAuth2LinkAccount: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), -})) - -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth/auth', () => ({ - auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, - getSession: mockGetSession, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, -})) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), -})) - -import { GET } from '@/app/api/auth/oauth2/authorize/route' - -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' -const LINK_URL = 'https://provider.example/authorize?state=abc' - -function authorizeRequest(query: Record) { - const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value) - } - return createMockRequest('GET', undefined, {}, url.toString()) -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - displayName: 'Work Gmail', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } -} - -describe('OAuth2 authorize route', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - mockOAuth2LinkAccount.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ url: LINK_URL }), - headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, - }) - }) - - describe('plain connect (no credentialId)', () => { - it('creates a draft with credentialId null and redirects to the provider', async () => { - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER_ID, - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - credentialId: null, - }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) - ) - }) - - it('numbers the draft display name when the default collides with an existing credential', async () => { - dbChainMockFns.where - .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) - .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) - - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: "Justin's Gmail 2" }) - ) - }) - - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) - }) - - it('redirects to login when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toContain('/login') - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects without workspace write access', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - }) - - describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).toHaveBeenCalledWith( - CREDENTIAL_ID, - USER_ID, - expect.objectContaining({ workspaceAccess: expect.anything() }) - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: CREDENTIAL_ID }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) - ) - }) - - it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) - ) - - await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: 'Renamed By User' }) - ) - }) - - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { - for (const providerId of ['trello', 'shopify']) { - const response = await GET( - authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` - ) - } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the caller is not a credential admin and writes no draft', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the credential belongs to a different workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects when the credential does not exist', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: 'cred-missing', - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects a non-oauth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects when the query providerId does not match the credential provider', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'slack', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 2eb916fa53a..2a9799d7863 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -5,7 +5,6 @@ import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -29,12 +28,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query + const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback @@ -51,65 +45,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } - let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) - } - - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) - } - reconnectDisplayName = actor.credential.displayName - } - // Create the draft before initiating the link so it is guaranteed to exist // (and freshly clocked) when the OAuth callback's `account.create.after` // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + await createConnectDraft({ userId, workspaceId, providerId }) const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, callbackURL }, diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 012b6f9f458..f14f6a1bfa8 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -15,9 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInE2B, mockExecuteInIsolatedVM, - mockFetchWorkspaceFileBuffer, mockGetWorkspaceFile, - mockResolveWorkspaceFileReference, mockUpdateWorkspaceFileContent, mockUploadFile, mockValidateWorkspaceFileWriteTarget, @@ -25,9 +23,7 @@ const { } = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), - mockFetchWorkspaceFileBuffer: vi.fn(), mockGetWorkspaceFile: vi.fn(), - mockResolveWorkspaceFileReference: vi.fn(), mockUpdateWorkspaceFileContent: vi.fn(), mockUploadFile: vi.fn(), mockValidateWorkspaceFileWriteTarget: vi.fn(), @@ -41,7 +37,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({ vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: mockExecuteInE2B, executeShellInE2B: vi.fn(), - SIM_RESULT_PREFIX: '__SIM_RESULT__=', })) vi.mock('@/lib/copilot/request/tools/files', () => ({ @@ -86,9 +81,7 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, getWorkspaceFile: mockGetWorkspaceFile, - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, updateWorkspaceFileContent: mockUpdateWorkspaceFileContent, uploadWorkspaceFile: vi.fn(), })) @@ -145,8 +138,6 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) - mockResolveWorkspaceFileReference.mockResolvedValue(null) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, vfsPath: target.path, @@ -539,152 +530,6 @@ describe('Function Execute API Route', () => { expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() }) - it('rejects sandboxPath outputs when the call would run in isolated-vm (E2B enabled, JS without imports)', async () => { - envFlagsMock.isE2bEnabled = true - - const req = createMockRequest('POST', { - code: 'return "content"', - language: 'javascript', - workspaceId: 'workspace-1', - outputs: { - files: [ - { - path: 'files/doc.md', - mode: 'overwrite', - sandboxPath: '/home/user/doc.md', - }, - ], - }, - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(422) - expect(data.success).toBe(false) - expect(data.error).toContain('no sandbox filesystem') - expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() - expect(mockExecuteInE2B).not.toHaveBeenCalled() - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() - }) - - it('rejects sandbox file mounts when the call would run in isolated-vm', async () => { - const req = createMockRequest('POST', { - code: 'return 1', - language: 'javascript', - workspaceId: 'workspace-1', - _sandboxFiles: [{ path: '/home/user/files/data.csv', content: 'a,b\n1,2' }], - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(422) - expect(data.success).toBe(false) - // E2B is disabled in this test, so the remediation must name that cause - // instead of suggesting python (which would also fail without E2B). - expect(data.error).toContain('E2B is not enabled') - expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled() - }) - - it('flags an overwrite export whose bytes are identical to the current file content as unchanged', async () => { - envFlagsMock.isE2bEnabled = true - const staleContent = '# doc\nunchanged mounted content\n' - mockExecuteInE2B.mockResolvedValueOnce({ - result: 'done', - stdout: 'ok', - sandboxId: 'sandbox-123', - exportedFiles: { '/home/user/doc.md': staleContent }, - }) - mockResolveWorkspaceFileReference.mockResolvedValue({ - id: 'wf_doc', - name: 'doc.md', - size: Buffer.byteLength(staleContent, 'utf-8'), - key: 'workspace/doc.md', - }) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from(staleContent, 'utf-8')) - - const req = createMockRequest('POST', { - code: 'print("done")', - language: 'python', - workspaceId: 'workspace-1', - outputs: { - files: [ - { - path: 'files/doc.md', - mode: 'overwrite', - sandboxPath: '/home/user/doc.md', - mimeType: 'text/markdown', - }, - ], - }, - }) - - const response = await POST(req) - const data = await response.json() - - // Idempotent overwrites (retries, unchanged regenerations) must not fail; - // the write proceeds and the receipt carries the loud unchanged signal so - // the model can tell its "new content" never reached the sandbox file. - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) - expect(data.output.result.unchanged).toBe(true) - expect(data.output.result.message).toContain('byte-identical to the previous version') - expect(data.output.result.message).toContain('/home/user/doc.md') - }) - - it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { - envFlagsMock.isE2bEnabled = true - const newContent = '# doc\nnew content\n' - mockExecuteInE2B.mockResolvedValueOnce({ - result: 'done', - stdout: 'ok', - sandboxId: 'sandbox-123', - exportedFiles: { '/home/user/doc.md': newContent }, - }) - mockResolveWorkspaceFileReference.mockResolvedValue({ - id: 'wf_doc', - name: 'doc.md', - size: 36728, - key: 'workspace/doc.md', - }) - - const req = createMockRequest('POST', { - code: 'print("done")', - language: 'python', - workspaceId: 'workspace-1', - outputs: { - files: [ - { - path: 'files/doc.md', - mode: 'overwrite', - sandboxPath: '/home/user/doc.md', - mimeType: 'text/markdown', - }, - ], - }, - }) - - const response = await POST(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - // Sizes differ, so the current content is never downloaded for comparison. - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(data.output.result.size).toBe(Buffer.byteLength(newContent, 'utf-8')) - expect(data.output.result.previousSize).toBe(36728) - expect(data.output.result.sha256).toMatch(/^[0-9a-f]{64}$/) - expect(data.output.result.unchanged).toBe(false) - expect(data.output.result.message).toContain('replaced 36728 bytes') - expect(data.output.result.message).toContain('sha256:') - // The python wrapper prints the marker with a leading \n so it always - // starts a fresh line even after non-newline-terminated user output. - const e2bCode = mockExecuteInE2B.mock.calls[0][0].code as string - expect(e2bCode).toContain("print('\\n__SIM_RESULT__=' + json.dumps(__sim_result__))") - }) - it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) @@ -954,10 +799,6 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) - expect(mockExecuteInIsolatedVM).toHaveBeenCalledWith( - expect.objectContaining({ timeoutMs: 10000 }), - expect.any(Object) - ) }) it.concurrent('should handle empty parameters object', async () => { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 20568fd2010..33ab418dac5 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { functionExecuteContract } from '@/lib/api/contracts' @@ -19,7 +18,7 @@ import { import { isE2bEnabled } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { executeInE2B, executeShellInE2B, SIM_RESULT_PREFIX } from '@/lib/execution/e2b' +import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys' @@ -37,10 +36,6 @@ import { import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' @@ -904,49 +899,6 @@ async function functionJsonResponse( ) } -/** - * Compares an about-to-be-exported buffer against the overwrite target's - * current content. `identical: true` means the export is a byte-for-byte no-op: - * either a legitimately idempotent regeneration, or the incident signature of - * code that never wrote to the declared sandboxPath (the file still holds the - * mounted input). Only the model can tell those apart, so callers surface the - * fact loudly in the receipt instead of failing the write. Comparison failures - * never block the write; the current content is only downloaded when the sizes - * already match. - */ -async function checkOverwriteTarget( - workspaceId: string, - targetPath: string, - buffer: Buffer -): Promise<{ previousSize?: number; identical: boolean }> { - try { - const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) - if (!existing) return { identical: false } - if (existing.size !== buffer.length) { - return { previousSize: existing.size, identical: false } - } - const current = await fetchWorkspaceFileBuffer(existing) - return { previousSize: existing.size, identical: current.equals(buffer) } - } catch { - return { identical: false } - } -} - -function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) { - return `(${bytes} bytes${ - previousSize !== undefined ? `, replaced ${previousSize} bytes` : '' - }, sha256:${sha256.slice(0, 16)})` -} - -function exportUnchangedNote(sandboxPath?: string): string { - return ( - 'WARNING: content is byte-identical to the previous version — nothing changed.' + - (sandboxPath - ? ` If you expected new content, your code did not modify the sandbox file at "${sandboxPath}" (it still holds the mounted input); write the new content to exactly that path and export again.` - : ' If you expected new content, the code returned the same bytes as before.') - ) -} - async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string @@ -1030,16 +982,7 @@ async function maybeExportSandboxFileToWorkspace(args: { const targetPath = overwriteFileId || outputPath const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') - let previousSize: number | undefined - let unchanged = false - if (mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) - previousSize = check.previousSize - unchanged = check.identical - } - try { - const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: authUserId, @@ -1058,28 +1001,17 @@ async function maybeExportSandboxFileToWorkspace(args: { mode, mimeType: resolvedMimeType, size: fileBuffer.length, - previousSize, - sha256, - unchanged, }) return NextResponse.json({ success: true, output: { result: { - message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt( - fileBuffer.length, - previousSize, - sha256 - )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`, + message: `Sandbox file exported to ${written.vfsPath}`, fileId: written.id, fileName: written.name, vfsPath: written.vfsPath, downloadUrl: written.downloadUrl, sandboxPath: outputSandboxPath, - size: fileBuffer.length, - previousSize, - sha256, - unchanged, }, stdout: cleanStdout(stdout), executionTime, @@ -1268,14 +1200,6 @@ async function maybeExportSandboxFilesToWorkspace(args: { const buffer = prepared.isBinary ? Buffer.from(prepared.content, 'base64') : Buffer.from(prepared.content, 'utf-8') - let previousSize: number | undefined - let unchanged = false - if (prepared.target.mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) - previousSize = check.previousSize - unchanged = check.identical - } - const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, userId: args.authUserId, @@ -1290,18 +1214,8 @@ async function maybeExportSandboxFilesToWorkspace(args: { mode: prepared.file.mode ?? 'create', mimeType: prepared.resolvedMimeType, size: prepared.size, - previousSize, - sha256, - unchanged, - }) - writtenFiles.push({ - ...written, - sandboxPath: prepared.sandboxPath, - exportedBytes: buffer.length, - previousSize, - sha256, - unchanged, }) + writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath }) } } catch (error) { return NextResponse.json( @@ -1318,27 +1232,11 @@ async function maybeExportSandboxFilesToWorkspace(args: { ) } - const unchangedFiles = writtenFiles.filter((file) => file.unchanged) return NextResponse.json({ success: true, output: { result: { - message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles - .map( - (file) => - `${file.vfsPath} ${formatExportReceipt( - file.exportedBytes, - file.previousSize, - file.sha256 - )}${file.unchanged ? ' [UNCHANGED]' : ''}` - ) - .join('; ')}${ - unchangedFiles.length > 0 - ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${ - unchangedFiles.length === 1 ? 'is' : 'are' - } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.` - : '' - }`, + message: `Exported ${writtenFiles.length} sandbox files`, files: writtenFiles.map((file) => ({ fileId: file.id, fileName: file.name, @@ -1346,10 +1244,6 @@ async function maybeExportSandboxFilesToWorkspace(args: { backingVfsPath: file.backingVfsPath, downloadUrl: file.downloadUrl, sandboxPath: file.sandboxPath, - size: file.exportedBytes, - previousSize: file.previousSize, - sha256: file.sha256, - unchanged: file.unchanged, })), }, stdout: cleanStdout(args.stdout), @@ -1612,29 +1506,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - // Sandbox file mounts and sandboxPath exports only exist in the E2B - // runtime; isolated-vm has no filesystem. Silently dropping a declared - // sandbox input/output here produced "export succeeded" responses with - // zero bytes written, so refuse the call instead. The remediation depends - // on WHY this call runs in isolated-vm — "switch to python" is a dead end - // when E2B is disabled or the call is a custom tool. - if (!useE2B && (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length)) { - const remediation = !isE2bEnabled - ? "E2B is not enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)." - : isCustomTool - ? "custom tools always run in the isolated JavaScript VM, which has no sandbox filesystem. Pass input data via params and return output as the code's return value." - : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the E2B sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.' - return functionJsonResponse( - { - success: false, - error: `Sandbox file inputs/outputs are unavailable for this call: ${remediation}`, - output: { result: null, stdout: '', executionTime: Date.now() - startTime }, - }, - routeContext, - { status: 422 } - ) - } - if (useE2B) { logger.info(`[${requestId}] E2B status`, { enabled: isE2bEnabled, @@ -1672,11 +1543,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ' const __sim_result = await (async () => {', ` ${codeBody.split('\n').join('\n ')}`, ' })();', - // Leading \n guarantees the marker starts a fresh line even when user - // code's last stdout write was not newline-terminated (chunks are - // concatenated verbatim on the parse side, so a glued marker would - // otherwise be missed silently). - ` console.log('\\n${SIM_RESULT_PREFIX}' + JSON.stringify(__sim_result));`, + " console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));", ' } catch (error) {', ' console.log(String((error && (error.stack || error.message)) || error));', ' throw error;', @@ -1768,8 +1635,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { 'def __sim_main__():', ...resolvedCode.split('\n').map((l) => ` ${l}`), '__sim_result__ = __sim_main__()', - // Leading \n: same fresh-line guarantee as the JS wrapper's marker. - `print('\\n${SIM_RESULT_PREFIX}' + json.dumps(__sim_result__))`, + "print('__SIM_RESULT__=' + json.dumps(__sim_result__))", ].join('\n') const codeForE2B = prologue + wrapped diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts deleted file mode 100644 index 15860b3e6e0..00000000000 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * @vitest-environment node - */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockTransaction, - mockSelectRows, - mockFilterForkableChatFiles, - mockListForkableChatFiles, - mockPlanChatFileCopies, - mockExecuteChatFileBlobCopies, - mockLoadCopilotChatMessages, - mockAppendCopilotChatMessages, - mockAssertActiveWorkspaceAccess, - mockFetchGo, - mockPublishStatusChanged, - mockCaptureServerEvent, - mockDeleteWhere, - mockRemoveChatResources, -} = vi.hoisted(() => ({ - mockTransaction: vi.fn(), - mockSelectRows: vi.fn(), - // Real (pure) cut semantics so tests drive selection through row.messageId: - // rows with a NULL/undefined messageId are kept in every fork. - mockFilterForkableChatFiles: vi.fn( - (rows: Array<{ messageId?: string | null }>, kept: ReadonlySet) => - rows.filter((row) => !row.messageId || kept.has(row.messageId)) - ), - mockListForkableChatFiles: vi.fn(), - mockPlanChatFileCopies: vi.fn(), - mockExecuteChatFileBlobCopies: vi.fn(), - mockLoadCopilotChatMessages: vi.fn(), - mockAppendCopilotChatMessages: vi.fn(), - mockAssertActiveWorkspaceAccess: vi.fn(), - mockFetchGo: vi.fn(), - mockPublishStatusChanged: vi.fn(), - mockCaptureServerEvent: vi.fn(), - mockDeleteWhere: vi.fn(), - mockRemoveChatResources: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => mockSelectRows(), - }), - }), - }), - delete: () => ({ - where: mockDeleteWhere, - }), - transaction: mockTransaction, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - workspaceId: 'copilotChats.workspaceId', - title: 'copilotChats.title', - model: 'copilotChats.model', - resources: 'copilotChats.resources', - previewYaml: 'copilotChats.previewYaml', - planArtifact: 'copilotChats.planArtifact', - config: 'copilotChats.config', - }, - workspaceFiles: { - id: 'workspaceFiles.id', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), -})) - -vi.mock('@/lib/copilot/resources/persistence', () => ({ - removeChatResources: mockRemoveChatResources, -})) - -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) - -vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({ - filterForkableChatFiles: mockFilterForkableChatFiles, - listForkableChatFiles: mockListForkableChatFiles, - planChatFileCopies: mockPlanChatFileCopies, - executeChatFileBlobCopies: mockExecuteChatFileBlobCopies, -})) - -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - loadCopilotChatMessages: mockLoadCopilotChatMessages, -})) - -vi.mock('@/lib/copilot/chat/messages-store', () => ({ - appendCopilotChatMessages: mockAppendCopilotChatMessages, -})) - -vi.mock('@/lib/copilot/chat-status', () => ({ - chatPubSub: { publishStatusChanged: mockPublishStatusChanged }, -})) - -vi.mock('@/lib/copilot/request/go/fetch', () => ({ - fetchGo: mockFetchGo, -})) - -vi.mock('@/lib/copilot/server/agent-url', () => ({ - getMothershipBaseURL: vi.fn().mockResolvedValue('http://mothership.test'), - getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), -})) - -vi.mock('@/lib/core/config/env', () => ({ env: {} })) - -vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: mockCaptureServerEvent, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, - isWorkspaceAccessDeniedError: () => false, -})) - -import { POST } from '@/app/api/mothership/chats/[chatId]/fork/route' - -const OLD_FILE_ID = 'wf_oldfile' -const NEW_FILE_ID = 'wf_newfile' - -const parentRow = { - id: 'chat-1', - userId: 'user-1', - type: 'mothership', - workspaceId: 'ws-1', - title: 'Generate Logs', - model: 'claude-opus-4-8', - resources: [{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' }], - previewYaml: null, - planArtifact: null, - config: null, -} - -const threeMessages = [ - { - id: 'msg-1', - role: 'user', - content: `See ![cat](/api/files/view/${OLD_FILE_ID})`, - timestamp: '2026-07-01T00:00:00.000Z', - }, - { - id: 'msg-2', - role: 'assistant', - content: 'Nice cat.', - timestamp: '2026-07-01T00:00:01.000Z', - }, - { - id: 'msg-3', - role: 'user', - content: 'A later message the fork must not keep.', - timestamp: '2026-07-01T00:00:02.000Z', - }, -] - -/** Chat rows inserted through the mock transaction, captured for title assertions. */ -let insertedChatRows: Array> = [] -/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */ -let updatedChatRows: Array> = [] - -function makeTx() { - return { - insert: () => ({ - values: (v: Record) => { - insertedChatRows.push(v) - return { - returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }], - } - }, - }), - update: () => ({ - set: (v: Record) => { - updatedChatRows.push(v) - return { where: async () => undefined } - }, - }), - } -} - -function createRequest(chatId: string, body?: unknown) { - return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body ?? { upToMessageId: 'msg-2' }), - }) -} - -function makeContext(chatId: string) { - return { params: Promise.resolve({ chatId }) } -} - -describe('POST /api/mothership/chats/[chatId]/fork', () => { - beforeEach(() => { - vi.clearAllMocks() - insertedChatRows = [] - updatedChatRows = [] - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ - userId: 'user-1', - isAuthenticated: true, - }) - mockSelectRows.mockResolvedValue([parentRow]) - mockListForkableChatFiles.mockResolvedValue([]) - mockLoadCopilotChatMessages.mockResolvedValue(threeMessages) - mockPlanChatFileCopies.mockResolvedValue({ - idMap: new Map(), - keyMap: new Map(), - blobTasks: [], - }) - mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] }) - mockAppendCopilotChatMessages.mockResolvedValue(undefined) - mockDeleteWhere.mockResolvedValue(undefined) - mockRemoveChatResources.mockResolvedValue(undefined) - mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) - mockFetchGo.mockResolvedValue({ ok: true }) - mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb(makeTx()) - ) - }) - - it('rejects unauthenticated callers', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ - userId: null, - isAuthenticated: false, - }) - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - expect(res.status).toBe(401) - }) - - it('404s when the chat belongs to another user', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - expect(res.status).toBe(404) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('404s for non-mothership chats', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - expect(res.status).toBe(404) - }) - - it('400s when upToMessageId is missing', async () => { - const res = await POST(createRequest('chat-1', {}), makeContext('chat-1')) - expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('400s when upToMessageId is an empty string', async () => { - const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1')) - expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('400s when the message is not in the chat', async () => { - const res = await POST( - createRequest('chat-1', { upToMessageId: 'msg-unknown' }), - makeContext('chat-1') - ) - expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('applies the timeline cut: kept message ids drive the file selection', async () => { - // Two uploads born pre-cut, one born post-cut, and one legacy row with no - // birth message. The single chat-owned read is cut in memory: everything - // but the post-cut row. - const preCutUpload = { id: 'wf_up', size: 1, context: 'mothership', messageId: 'msg-1' } - const secondPreCut = { id: 'wf_up2', size: 1, context: 'mothership', messageId: 'msg-2' } - const postCutUpload = { id: 'wf_late', size: 1, context: 'mothership', messageId: 'msg-3' } - const legacyRow = { id: 'wf_legacy', size: 1, context: 'mothership', messageId: null } - mockListForkableChatFiles.mockResolvedValue([ - preCutUpload, - secondPreCut, - postCutUpload, - legacyRow, - ]) - - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - expect(res.status).toBe(200) - - // The cut runs over the single read with the kept slice (inclusive of - // msg-2, excluding msg-3). - expect(mockListForkableChatFiles).toHaveBeenCalledTimes(1) - const filterCall = mockFilterForkableChatFiles.mock.calls[0] - expect(filterCall[1]).toEqual(new Set(['msg-1', 'msg-2'])) - - // The copy plan receives the cut set — the pre-cut uploads plus the - // legacy no-birthdate row; the post-cut upload stays behind. - expect(mockPlanChatFileCopies.mock.calls[0][0].rows).toEqual([ - preCutUpload, - secondPreCut, - legacyRow, - ]) - - // The appended transcript is the same inclusive slice. - const appended = mockAppendCopilotChatMessages.mock.calls[0] - expect(appended[1].map((m: { id: string }) => m.id)).toEqual(['msg-1', 'msg-2']) - }) - - it('forks the chat: copies kept uploads, rewrites references, clones agent state', async () => { - const blobTasks = [ - { - sourceKey: 'workspace/ws-1/old-cat.png', - targetKey: 'workspace/ws-1/new-cat.png', - context: 'mothership', - fileName: 'cat.png', - contentType: 'image/png', - }, - ] - mockListForkableChatFiles.mockResolvedValue([ - { size: 100, messageId: 'msg-1', workspaceId: 'ws-1' }, - ]) - mockPlanChatFileCopies.mockResolvedValue({ - idMap: new Map([[OLD_FILE_ID, NEW_FILE_ID]]), - keyMap: new Map([['workspace/ws-1/old-cat.png', 'workspace/ws-1/new-cat.png']]), - blobTasks, - }) - - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.success).toBe(true) - expect(typeof body.id).toBe('string') - - // The real rewriter runs: the kept message's view-URL points at the copy. - const appended = mockAppendCopilotChatMessages.mock.calls[0] - expect(appended[0]).toBe(body.id) - expect(appended[1][0].content).toBe(`See ![cat](/api/files/view/${NEW_FILE_ID})`) - - expect(mockExecuteChatFileBlobCopies).toHaveBeenCalledWith(blobTasks) - - const goCall = mockFetchGo.mock.calls[0] - expect(goCall[0]).toBe('http://mothership.test/api/chats/fork') - const goBody = JSON.parse(goCall[1].body) - // The copilot service only knows USER message ids, so the clone cut is the - // kept slice's last user message (msg-1), not the clicked assistant (msg-2). - expect(goBody).toEqual({ - sourceChatId: 'chat-1', - newChatId: body.id, - upToMessageId: 'msg-1', - userId: 'user-1', - }) - - expect(mockPublishStatusChanged).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - chatId: body.id, - type: 'created', - }) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'user-1', - 'task_forked', - { workspace_id: 'ws-1', source_chat_id: 'chat-1' }, - { groups: { workspace: 'ws-1' } } - ) - - // Forks are titled "Fork | ". - expect(insertedChatRows[0].title).toBe('Fork | Generate Logs') - }) - - it('still succeeds when the copilot-service clone fails (best-effort)', async () => { - mockFetchGo.mockRejectedValue(new Error('mothership unreachable')) - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - expect(res.status).toBe(200) - }) - - it('surfaces failed blob copies and cleans up their dead rows + resource chips', async () => { - mockExecuteChatFileBlobCopies.mockResolvedValue({ - copied: 1, - failed: 2, - failedCopyIds: ['wf_dead1', 'wf_dead2'], - }) - - const failedRes = await POST(createRequest('chat-1'), makeContext('chat-1')) - const body = await failedRes.json() - - expect(body.failedFileCopies).toBe(2) - - // The dead rows (committed, but no bytes behind them) are hard-deleted so - // they vanish from VFS listings and name resolution… - expect(mockDeleteWhere).toHaveBeenCalledWith({ - type: 'inArray', - field: 'workspaceFiles.id', - values: ['wf_dead1', 'wf_dead2'], - }) - // …and their resource chips are dropped from the new chat. - expect(mockRemoveChatResources).toHaveBeenCalledWith(body.id, [ - { type: 'file', id: 'wf_dead1', title: '' }, - { type: 'file', id: 'wf_dead2', title: '' }, - ]) - }) - - it('omits failedFileCopies and skips cleanup when every blob copies', async () => { - mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 3, failed: 0, failedCopyIds: [] }) - - const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1')) - - expect('failedFileCopies' in (await cleanRes.json())).toBe(false) - expect(mockDeleteWhere).not.toHaveBeenCalled() - expect(mockRemoveChatResources).not.toHaveBeenCalled() - }) - - it('copies pre-cut uploads and drops only post-cut ghosts', async () => { - // The source chat owns two more uploads (apple pre-cut, banana post-cut) - // beside the kept one, plus one shared workspace-file resource. The fork - // copies the kept upload AND the pre-cut apple; only the post-cut banana - // stays behind, so only its resource is dropped — not left pointing at - // the source chat. - mockSelectRows.mockResolvedValue([ - { - ...parentRow, - resources: [ - { type: 'file', id: OLD_FILE_ID, title: 'cat.png' }, - { type: 'file', id: 'wf_apple', title: 'apple.png' }, - { type: 'file', id: 'wf_banana', title: 'banana.png' }, - { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, - { type: 'workflow', id: 'wflow-1', title: 'My flow' }, - ], - }, - ]) - // Every chat-owned file of the source chat in the single read; messageId - // drives the in-memory cut. - mockListForkableChatFiles.mockResolvedValue([ - { id: OLD_FILE_ID, size: 100, context: 'mothership', messageId: 'msg-1' }, - { id: 'wf_apple', size: 50, context: 'mothership', messageId: 'msg-1' }, - { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, - ]) - mockPlanChatFileCopies.mockResolvedValue({ - idMap: new Map([ - [OLD_FILE_ID, NEW_FILE_ID], - ['wf_apple', 'wf_apple_copy'], - ]), - keyMap: new Map(), - blobTasks: [], - }) - - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - - expect(res.status).toBe(200) - // The plan received the cut set: the kept upload + pre-cut apple only. - expect(mockPlanChatFileCopies.mock.calls[0][0].rows.map((r: { id: string }) => r.id)).toEqual([ - OLD_FILE_ID, - 'wf_apple', - ]) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([ - { type: 'file', id: NEW_FILE_ID, title: 'cat.png' }, - { type: 'file', id: 'wf_apple_copy', title: 'apple.png' }, - { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, - { type: 'workflow', id: 'wflow-1', title: 'My flow' }, - ]) - }) - - it('drops ghosts even when the fork copies no files at all', async () => { - // Fork cut before the chat's only upload arrived: a guard that skips the - // resources update when idMap is empty would leave the ghost in place. - mockSelectRows.mockResolvedValue([ - { - ...parentRow, - resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }], - }, - ]) - mockListForkableChatFiles.mockResolvedValue([ - { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' }, - ]) - - const res = await POST(createRequest('chat-1'), makeContext('chat-1')) - - expect(res.status).toBe(200) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([]) - }) -}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 9f8011bd264..44b63c7f163 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -1,23 +1,13 @@ import { db } from '@sim/db' -import { copilotChats, workspaceFiles } from '@sim/db/schema' +import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq, inArray } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { - executeChatFileBlobCopies, - filterForkableChatFiles, - listForkableChatFiles, - planChatFileCopies, -} from '@/lib/copilot/chat/fork-chat-files' import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { - rewriteMessageFileRefs, - rewriteResourceFileRefs, -} from '@/lib/copilot/chat/rewrite-file-references' import { chatPubSub } from '@/lib/copilot/chat-status' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { @@ -28,7 +18,6 @@ import { createNotFoundResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' -import { removeChatResources } from '@/lib/copilot/resources/persistence' import type { MothershipResource } from '@/lib/copilot/resources/types' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' @@ -44,16 +33,7 @@ const logger = createLogger('ForkChatAPI') /** * POST /api/mothership/chats/[chatId]/fork * Creates a new chat branched from the given chat, keeping messages up to and - * including the specified message, along with the chat's uploads born - * at-or-before the fork point (a file travels iff the user message that - * carried it is kept). Resources and copilot-side state are copied. - * - * Every copied file gets a fresh row id and storage key, and every - * in-transcript file reference is re-pointed at the copies so the new chat - * survives deletion of the source chat. Mothership files remain excluded from - * workspace storage accounting. File resources whose chat-owned file was NOT - * copied (uploads born after the cut) are dropped from the new chat's resources - * rather than left as ghosts pointing at the source chat's files. + * including the specified message. Resources and copilot-side state are copied. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { @@ -102,36 +82,17 @@ export const POST = withRouteHandler( } const forkedMessages = messages.slice(0, forkIdx + 1) - // Single workspace_files read per fork: every chat-owned upload. The - // copied set is timeline-cut to the kept message slice in memory (files - // born after the fork point stay behind). - const chatOwnedFiles = await listForkableChatFiles(db, chatId) - const sourceFiles = filterForkableChatFiles( - chatOwnedFiles, - new Set(forkedMessages.map((m) => m.id)) - ) - - // Resources are stored as a jsonb array on the chat row. They carry no - // timestamps, so they can't be timeline-cut like messages — instead, - // file resources whose chat-owned file is NOT copied (uploads born - // after the cut) are dropped in the rewrite below; everything else is - // copied. + // Resources are stored as a jsonb array on the chat row — copy them directly. const parentResources = Array.isArray(parent.resources) ? (parent.resources as MothershipResource[]) : [] - // The source chat's chat-owned file ids (no cut) — the "is this - // resource a ghost?" test set for the rewrite. - const chatOwnedFileIds = new Set(chatOwnedFiles.map((row) => row.id)) - const newId = generateId() - // Strip a leading "Fork | " so titles don't stack prefixes when forking - // a forked chat. const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '') const title = `Fork | ${baseTitle}` const now = new Date() - const result = await db.transaction(async (tx) => { + const newChat = await db.transaction(async (tx) => { const [row] = await tx .insert(copilotChats) .values({ @@ -153,82 +114,16 @@ export const POST = withRouteHandler( if (!row) return null - // File rows FK the new chat row, so the plan runs after the insert. - const { idMap, keyMap, blobTasks } = await planChatFileCopies({ - tx, - rows: sourceFiles, - newChatId: newId, - userId, - now, - }) - - const maps = { fileIds: idMap, fileKeys: keyMap } - const newChatResources = rewriteResourceFileRefs(parentResources, maps, chatOwnedFileIds) - // Skip the redundant update only when the rewrite changed nothing: - // no ids re-pointed AND no ghost resources dropped. (idMap and keyMap - // are populated in lockstep, so idMap alone decides the first half.) - if (idMap.size > 0 || newChatResources.length !== parentResources.length) { - await tx - .update(copilotChats) - .set({ resources: newChatResources }) - .where(eq(copilotChats.id, newId)) - } - - await appendCopilotChatMessages( - newId, - rewriteMessageFileRefs(forkedMessages, maps), - { chatModel: parent.model }, - tx - ) - return { row, blobTasks } + await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx) + return row }) - if (!result) { + if (!newChat) { return createInternalServerErrorResponse('Failed to create forked chat') } - const newChat = result.row - - const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies(result.blobTasks) - if (failed > 0) { - // A failed blob copy leaves a committed row with no bytes behind it. - // Cleanly absent beats present-but-broken: hard-delete the dead rows - // (they vanish from the VFS listings and name resolution) and drop - // their resource chips from the new chat. Inline transcript embeds - // can't be healed — those 404 — which is what `failedFileCopies` in - // the response warns the user about. - try { - await db.delete(workspaceFiles).where(inArray(workspaceFiles.id, failedCopyIds)) - await removeChatResources( - newId, - failedCopyIds.map((id) => ({ type: 'file' as const, id, title: '' })) - ) - } catch (cleanupError) { - logger.error('Failed to clean up dead file rows after blob-copy failure', { - newChatId: newId, - failedCopyIds, - error: cleanupError, - }) - } - logger.warn('Some chat file blobs failed to copy during fork', { - chatId, - newChatId: newId, - copied, - failed, - }) - } // Clone copilot-service conversation state (messages, active_messages, memory files). // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. - // The service stamps MessageID only on USER messages (assistant rows carry - // Sim-local ids it has never seen), so hand it the kept slice's last user - // message — it clones through the end of that turn, matching this route's cut. - let goCutMessageId = upToMessageId - for (let i = forkedMessages.length - 1; i >= 0; i--) { - if (forkedMessages[i].role === 'user') { - goCutMessageId = forkedMessages[i].id - break - } - } try { const copilotHeaders: Record = { 'Content-Type': 'application/json' } if (env.COPILOT_API_KEY) { @@ -242,7 +137,7 @@ export const POST = withRouteHandler( body: JSON.stringify({ sourceChatId: chatId, newChatId: newId, - upToMessageId: goCutMessageId, + upToMessageId, userId, }), spanName: 'sim → go /api/chats/fork', @@ -273,11 +168,7 @@ export const POST = withRouteHandler( { groups: { workspace: parent.workspaceId ?? '' } } ) - return NextResponse.json({ - success: true, - id: newId, - ...(failed > 0 ? { failedFileCopies: failed } : {}), - }) + return NextResponse.json({ success: true, id: newId }) } catch (error) { if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index 0879cf10aa6..df4e2f41b13 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -173,7 +173,7 @@ describe('GET /api/mothership/chats/[chatId]', () => { expect(mockReadEvents).not.toHaveBeenCalled() }) - it('returns the live activeStreamId with a status-only snapshot (no events)', async () => { + it('returns the live activeStreamId when redis confirms the lock', async () => { mockGetAccessibleCopilotChat.mockResolvedValueOnce({ id: 'chat-live', type: 'mothership', @@ -185,57 +185,15 @@ describe('GET /api/mothership/chats/[chatId]', () => { updatedAt: new Date('2026-05-11T12:00:00Z'), }) mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' }) - const previewSession = { - id: 'preview-1', - previewVersion: 1, - status: 'active', - updatedAt: '2026-05-11T12:00:00Z', - } - mockReadFilePreviewSessions.mockResolvedValueOnce([previewSession]) const response = await GET(createRequest('chat-live'), makeContext('chat-live')) expect(response.status).toBe(200) const body = await response.json() expect(body.chat.activeStreamId).toBe('stream-live') - // Events are read only to synthesize the in-flight assistant turn for the - // initial paint; the client reconnects to the replay buffer for the rest. - // Status and preview sessions ARE shipped so hydration can gate the - // reconnect and seed the preview panel before the resume request lands. expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0') - expect(mockReadFilePreviewSessions).toHaveBeenCalledWith('stream-live') - expect(body.chat.streamSnapshot).toEqual({ - events: [], - previewSessions: [previewSession], - status: 'active', - }) - }) - - it('reports a terminal run status when the stream lock is still visible', async () => { - mockGetAccessibleCopilotChat.mockResolvedValueOnce({ - id: 'chat-finished', - type: 'mothership', - title: 'Finished', - messages: [], - resources: [], - conversationId: 'stream-finished', - createdAt: new Date('2026-05-11T12:00:00Z'), - updatedAt: new Date('2026-05-11T12:00:00Z'), - }) - mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'complete' }) - - const response = await GET(createRequest('chat-finished'), makeContext('chat-finished')) - expect(response.status).toBe(200) - const body = await response.json() - - // The run finished but the Redis lock hasn't cleared yet: the client - // must see the terminal status so it skips the reconnect entirely. - expect(body.chat.activeStreamId).toBe('stream-finished') - expect(body.chat.streamSnapshot).toEqual({ - events: [], - previewSessions: [], - status: 'complete', - }) + expect(body.chat.streamSnapshot).toBeDefined() + expect(body.chat.streamSnapshot.status).toBe('active') }) it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 99176cd6b23..2274a9582c7 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -50,12 +50,7 @@ export const GET = withRouteHandler( return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } - // The Redis replay buffer is read here only to synthesize the in-flight - // assistant turn for the initial paint. The raw events are NOT shipped - // to the client: when `activeStreamId` is set, the client reconnects to - // the replay buffer (from seq 0) via the stream resume endpoint, which - // is the source of truth for streaming state. - let liveTurnSnapshot: { + let streamSnapshot: { events: StreamBatchEvent[] previewSessions: FilePreviewSession[] status: string @@ -89,7 +84,7 @@ export const GET = withRouteHandler( return null }) - liveTurnSnapshot = { + streamSnapshot = { events: events.map(toStreamBatchEvent), previewSessions, status: @@ -116,7 +111,7 @@ export const GET = withRouteHandler( const effectiveMessages = buildEffectiveChatTranscript({ messages: normalizedMessages, activeStreamId: liveStreamId, - ...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}), + ...(streamSnapshot ? { streamSnapshot } : {}), }) return NextResponse.json({ @@ -129,19 +124,7 @@ export const GET = withRouteHandler( resources: Array.isArray(chat.resources) ? chat.resources : [], createdAt: chat.createdAt, updatedAt: chat.updatedAt, - // Events stay out of the payload (the resume endpoint replays them), - // but the client still needs the run status to skip reconnecting to - // an already-terminal stream, and the preview sessions to seed the - // file preview panel before the reconnect lands. - ...(liveTurnSnapshot - ? { - streamSnapshot: { - events: [], - previewSessions: liveTurnSnapshot.previewSessions, - status: liveTurnSnapshot.status, - }, - } - : {}), + ...(streamSnapshot ? { streamSnapshot } : {}), }, }) } catch (error) { diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 62eb7bc22ed..55c2f7cceaa 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -14,12 +14,12 @@ import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, } from '@/lib/copilot/generated/mothership-stream-v1' -import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -107,7 +107,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { requestId: providedRequestId, fileAttachments, contexts, - mcpTools, workflowId, executionId, userMetadata, @@ -148,31 +147,21 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content // double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime const agentMentions = contexts as unknown as ChatContext[] | undefined - const taggedMcpServerIds = (agentMentions ?? []).flatMap((context) => - context.kind === 'mcp' && context.serverId ? [context.serverId] : [] - ) - const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') const [ workspaceContext, integrationTools, - mothershipTools, + userSkillTool, userPermission, entitlements, agentContexts, ] = await Promise.all([ generateWorkspaceContext(workspaceId, userId), buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - Promise.all([ - buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), - buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), - ]).then((groups) => { - const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) - return [...byName.values()] - }), + buildUserSkillTool(workspaceId), getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), computeWorkspaceEntitlements(workspaceId, userId), processContextsServer( - nonMcpAgentMentions, + agentMentions, userId, lastUserMessage, workspaceId, @@ -202,30 +191,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(userMetadata ? { userMetadata } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), - ...(agentContexts.length > 0 || mothershipTools.length > 0 - ? { - contexts: [ - ...agentContexts, - ...(mothershipTools.length > 0 - ? [ - { - type: 'mcp', - content: [ - 'The following MCP tools are explicitly enabled for this request.', - 'Load one with load_custom_tool({ type: "mcp", name: "" }) before calling it.', - 'Do not narrate discovery, loading, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', - ...mothershipTools.map( - (tool) => `- ${tool.name}: ${tool.description || tool.name}` - ), - ].join('\n'), - }, - ] - : []), - ], - } - : {}), + ...(agentContexts.length > 0 ? { contexts: agentContexts } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(mothershipTools.length > 0 ? { mothershipTools } : {}), + ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), ...(entitlements.length > 0 ? { entitlements } : {}), } diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index bb9f2618984..fa55168db32 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -18,7 +18,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getProviderIdFromServiceId, OAUTH_PROVIDERS, @@ -31,6 +30,18 @@ import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections' const logger = createLogger('ConnectOAuthModal') +/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ +const DISPLAY_NAME_MAX_LENGTH = 255 + +/** + * Reserved tail budget when truncating the username so the auto-numbering + * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. + */ +const COLLISION_SUFFIX_RESERVATION = 5 + +/** Upper bound for the auto-numbering search — pathological if ever reached. */ +const MAX_COLLISION_INDEX = 10000 + const EMPTY_SCOPES: readonly string[] = [] type ServiceIcon = ComponentType<{ className?: string }> @@ -40,6 +51,44 @@ function isHiddenScope(scope: string): boolean { return scope.includes('userinfo.email') || scope.includes('userinfo.profile') } +/** + * Default credential display name. Produces `"{Name}'s {Service}"` when the + * user's name is known, falling back to `"My {Service}"` otherwise. The + * username is truncated so the full string (including any auto-numbering + * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. + * + * When the base name collides with an existing credential in `takenNames`, + * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison + * is case-insensitive to match the duplicate-detection used elsewhere in the + * modal. + */ +function defaultDisplayName( + userName: string | null | undefined, + serviceName: string, + takenNames: ReadonlySet +): string { + const trimmed = userName?.trim() + let base: string + if (trimmed) { + const suffix = `'s ${serviceName}` + const nameBudget = Math.max( + 0, + DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION + ) + const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed + base = `${safeName}${suffix}` + } else { + base = `My ${serviceName}` + } + + if (!takenNames.has(base.toLowerCase())) return base + for (let n = 2; n < MAX_COLLISION_INDEX; n++) { + const candidate = `${base} ${n}` + if (!takenNames.has(candidate.toLowerCase())) return candidate + } + return base +} + /** * Resolves the display name + icon for an OAuth `provider`/`serviceId` pair, * preferring the most specific service entry and falling back to the base @@ -206,7 +255,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } if (!isConnect || prefilled.current || credentialsLoading) return prefilled.current = true - setDisplayName(defaultCredentialDisplayName(userName, providerName, takenNames)) + setDisplayName(defaultDisplayName(userName, providerName, takenNames)) setDescription('') setValidationError(null) setSubmitError(null) diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index b2b4e22d1c6..91ebc37ea42 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -10,20 +10,19 @@ import { ChipModalHeader, cn, Duplicate, - Split, ThumbsDown, ThumbsUp, Tooltip, toast, } from '@sim/emcn' +import { GitBranch } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' import { useForkMothershipChat } from '@/hooks/queries/mothership-chats' import { useFolderStore } from '@/stores/folders/store' -const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question' +const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file' function toPlainText(raw: string): string { return ( @@ -154,11 +153,6 @@ export const MessageActions = memo(function MessageActions({ if (!chatId || !messageId || forkChat.isPending) return try { const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId }) - if (result.failedFileCopies) { - toast.warning( - `${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork` - ) - } useFolderStore.getState().clearChatSelection() router.push(`/workspace/${params.workspaceId}/chat/${result.id}`) } catch { @@ -168,10 +162,7 @@ export const MessageActions = memo(function MessageActions({ const hasContent = Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) - // A live (just-streamed) assistant message carries a synthetic id that the - // persisted transcript doesn't know — forking it would 400. The button - // appears once the transcript refetch swaps in the persisted message id. - const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId)) + const canFork = false if (!hasContent && !canSubmitFeedback && !canFork) return null return ( @@ -229,15 +220,15 @@ export const MessageActions = memo(function MessageActions({ - Branch in new chat + Fork from here )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts index 586f2bc2ff2..b9392de02c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts @@ -166,22 +166,6 @@ describe('divider Backspace', () => { expect(selection.to).toBe(doc.content.size) editor.destroy() }) - - it('only paints a divider inside a range selection while the editor has focus', () => { - const editor = editorWith('

a


b

') - const divider = editor.view.dom.querySelector('hr') - editor.commands.selectAll() - - expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) - - editor.view.dom.dispatchEvent(new FocusEvent('focus')) - expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(true) - - editor.view.dom.dispatchEvent(new FocusEvent('blur')) - expect(editor.state.selection.empty).toBe(false) - expect(divider?.classList.contains('rich-leaf-in-selection')).toBe(false) - editor.destroy() - }) }) describe('empty wrapped-block Backspace', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts index bdbac57bfc6..0d5bc22801c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts @@ -20,8 +20,6 @@ const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote']) /** Item node types a list is built from, used to detect an empty item's position within its list. */ const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem']) -const RICH_LEAF_SELECTION_FOCUS_KEY = new PluginKey('richLeafSelectionFocus') - /** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */ function isInsideWrapper($from: ResolvedPos): boolean { for (let depth = $from.depth - 1; depth >= 1; depth--) { @@ -159,11 +157,10 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b * ({@link selectAdjacentSelectedLeaf}). (The `Mod-Shift-Arrow` block-reorder chords live separately * in `./block-mover.ts`.) * - * Plus a plugin that (a) highlights dividers/images falling inside a focused range selection (e.g. - * select-all), which the browser's native text highlight skips because leaves carry no text; hiding - * that custom decoration on blur keeps it in sync with the native text highlight, and (b) flags the - * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide - * its otherwise-stray caret. + * Plus a plugin that (a) highlights dividers/images falling inside a range selection (e.g. select-all), + * which the browser's native text highlight skips because leaves carry no text, and (b) flags the + * editor (`data-gap-between-leaves`) while a gap cursor sits between two leaves, so the CSS can hide its + * otherwise-stray caret. */ export const RichMarkdownKeymap = Extension.create({ name: 'richMarkdownKeymap', @@ -234,34 +231,11 @@ export const RichMarkdownKeymap = Extension.create({ addProseMirrorPlugins() { return [ new Plugin({ - key: RICH_LEAF_SELECTION_FOCUS_KEY, - state: { - init: () => false, - apply(transaction, focused) { - const nextFocused = transaction.getMeta(RICH_LEAF_SELECTION_FOCUS_KEY) - return typeof nextFocused === 'boolean' ? nextFocused : focused - }, - }, + key: new PluginKey('richLeafSelectionHighlight'), props: { - handleDOMEvents: { - focus(view) { - view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, true)) - return false - }, - blur(view) { - view.dispatch(view.state.tr.setMeta(RICH_LEAF_SELECTION_FOCUS_KEY, false)) - return false - }, - }, decorations(state) { const { selection } = state - if ( - RICH_LEAF_SELECTION_FOCUS_KEY.getState(state) !== true || - selection.empty || - selection instanceof NodeSelection - ) { - return null - } + if (selection.empty || selection instanceof NodeSelection) return null const decorations: Decoration[] = [] state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { if (SELECTABLE_LEAVES.has(node.type.name)) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx index a98fe3f98fa..fde79283857 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx @@ -8,7 +8,7 @@ import { Task, Workflow, } from '@sim/emcn/icons' -import { AgentSkillsIcon, McpIcon } from '@/components/icons' +import { AgentSkillsIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import type { ChatContextKind, ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types' import { getBareIconStyle } from '@/blocks/icon-color' @@ -99,8 +99,4 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record , }, - mcp: { - label: 'MCP server', - renderIcon: ({ className }) => , - }, } 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 62e8bb55d45..cf02091848c 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 @@ -174,7 +174,7 @@ interface NarrationTextProps { } /** - * A narration row inside an agent group. The live tail row is + * A narration (thinking/text) row inside an agent group. The live tail row is * paced with {@link useSmoothText} so streamed chunks reveal word-by-word * instead of popping in, matching the top-level text treatment. */ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx deleted file mode 100644 index b432e67efc3..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @vitest-environment node - */ -import type { ReactNode, SVGProps } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { getBlockByToolName } from '@/blocks/registry' -import { ToolCallItem } from './tool-call-item' - -vi.mock('@/components/ui', () => ({ - ShimmerText: ({ children }: { children: ReactNode }) => {children}, -})) - -describe('ToolCallItem', () => { - it.each(['executing', 'success', 'error', 'cancelled'] as const)( - 'renders the %s tool row without an icon', - (status) => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain(' { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Wrote brief.md') - expect(markup).not.toContain('Writing brief.md') - }) - - it('defensively applies the completed verb for every successful tool row', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Compared workflows') - expect(markup).not.toContain('Comparing workflows') - }) - - it('renders the owning integration icon for a resolved integration operation', () => { - vi.mocked(getBlockByToolName).mockReturnValueOnce({ - name: 'Gmail', - icon: (props: SVGProps) => , - } as ReturnType) - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain(' { - vi.mocked(getBlockByToolName).mockReturnValueOnce({ - name: 'Gmail', - icon: (props: SVGProps) => , - } as ReturnType) - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain(' { - if (toolName !== CallIntegrationTool.id) return undefined - const toolId = params?.toolId ?? extractStreamingStringArgument(streamingArgs, 'toolId') - return typeof toolId === 'string' ? getBlockByToolName(toolId) : undefined - }, [toolName, params, streamingArgs]) - const liveWorkspaceFileTitle = useMemo(() => { if (toolName !== WorkspaceFile.id || !streamingArgs) return null const titleMatch = streamingArgs.match(/"title"\s*:\s*"([^"]+)"/) @@ -100,9 +83,12 @@ export function ToolCallItem({ const isExecuting = resolveToolDisplayState(status) === 'spinner' const liveTitle = liveWorkspaceFileTitle || displayTitle - const title = getToolStatusDisplayTitle(liveTitle, status) + const title = + status === 'success' && liveWorkspaceFileTitle + ? (getToolCompletedTitle(liveTitle) ?? liveTitle) + : liveTitle - const BlockIcon = (readBlock ?? gatewayBlock ?? getBlockByToolName(toolName))?.icon + const BlockIcon = readBlock?.icon return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 1047fafa3e9..b858c36ad1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -102,8 +102,7 @@ function endsInlineWord(value: string): boolean { function nextInlineSegmentLabel(segment?: ContentSegment): string { if (!segment) return '' - // Thinking segments are never rendered, so they contribute no following text. - if (segment.type === 'text') return segment.content + if (segment.type === 'text' || segment.type === 'thinking') return segment.content if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || '' return '' } @@ -360,25 +359,17 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string isStreaming?: boolean - /** Transcript-derived answers for this message's question card (renders the recap). */ - questionAnswers?: string[] onOptionSelect?: (id: string) => void - onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void - /** Reports whether this segment is actively painting text or its own pending-tag indicator. */ - onStreamActivityChange?: (active: boolean) => void } function ChatContentInner({ content, isStreaming = false, - questionAnswers, onOptionSelect, - onQuestionDismiss, onWorkspaceResourceSelect, onRevealStateChange, - onStreamActivityChange, }: ChatContentProps) { const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -388,8 +379,7 @@ function ChatContentInner({ const displayContent = useMemo(() => sanitizeChatDisplayContent(content), [content]) const streamedContent = useSmoothText(displayContent, isStreaming) - const hasRevealBacklog = streamedContent.length < displayContent.length - const isRevealing = isStreaming || hasRevealBacklog + const isRevealing = isStreaming || streamedContent.length < displayContent.length useEffect(() => { onRevealStateChangeRef.current?.(isRevealing) @@ -412,9 +402,9 @@ function ChatContentInner({ * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content * without the animate plugin bails at every unoverridden element (`p`, * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in - * place. Every instance renders through the streaming parser (see - * `streamingTree` below) so the remount only sheds the spans, never - * re-interprets the markdown. + * place. The settled instance keeps the streaming parser (`parserTree` + * below) so the remount only sheds the spans, never re-interprets the + * markdown. * * The drain is deliberately one-way: a stream that resumes afterwards * (reconnect/continuation) reveals paced but unfaded, because re-arming @@ -459,18 +449,19 @@ function ChatContentInner({ }, [isRevealing, animationDrained, streamedThisSession]) /** - * Every mount renders through the streaming parser (remend + - * incomplete-markdown repair + block-split) — `mode='static'` is never used. - * The two pipelines parse edge-case markdown differently (unbalanced fences, - * list continuation across blocks), so a message you watched stream would - * render subtly differently from the same message reloaded from the DB; one - * pipeline makes in-session and refreshed renders byte-identical. The rows - * are virtualized, so only visible messages pay the block-split mount cost. - * `streamingTree` (the remount key and animation props) still drops at - * drain, so a settled instance re-renders through the SAME parser minus the - * per-word animation spans — identical pixels. + * `parserTree` (drives `mode`) stays latched for the mount's life: streaming + * mode is the only one that applies remend/incomplete-markdown repair and + * block-split parsing, so a settled message must KEEP the streaming parser — + * swapping to `mode='static'` at drain re-parses the same source through a + * different pipeline (no remend, whole-doc parse) and visibly flashes on any + * reply with unbalanced markdown. `streamingTree` (drives the remount key + * and animation props) additionally drops at drain, so the settled instance + * re-renders through the SAME parser minus the per-word animation spans — + * byte-identical pixels. Only never-streamed mounts (reloaded history) + * render static. */ - const streamingTree = (isRevealing || streamedThisSession) && !animationDrained + const parserTree = isRevealing || streamedThisSession + const streamingTree = parserTree && !animationDrained /** * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a @@ -499,12 +490,6 @@ function ChatContentInner({ () => parseSpecialTags(streamedContent, isRevealing), [streamedContent, isRevealing] ) - const hasPendingIndicator = parsed.hasPendingTag && isRevealing - - useEffect(() => { - onStreamActivityChange?.(hasRevealBacklog || hasPendingIndicator) - return () => onStreamActivityChange?.(false) - }, [hasPendingIndicator, hasRevealBacklog, onStreamActivityChange]) type BlockSegment = Exclude< ContentSegment, @@ -538,11 +523,7 @@ function ChatContentInner({ `[${label}](<#wsres-${s.data.type}-${ref}>)`, nextSegment ) - } else if (s.type === 'thinking') { - // Model-emitted tag bodies are reasoning, not answer text — - // never rendered (matches the block-level thinking omission in - // message-content and the tag stripping in the inbox executor). - } else if (s.type === 'text') { + } else if (s.type === 'text' || s.type === 'thinking') { pendingMarkdown += s.content } else { flushMarkdown() @@ -573,6 +554,7 @@ function ChatContentInner({ > ) })} - {hasPendingIndicator && } + {parsed.hasPendingTag && isRevealing && }
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts index 8151e32f11c..f211a5f42e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts @@ -2,5 +2,4 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group' export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group' export { ChatContent } from './chat-content' export { Options } from './options' -export { QuestionDisplay } from './question' export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts deleted file mode 100644 index 5272577cfda..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - formatQuestionAnswerMessage, - parseQuestionAnswerMessage, - QuestionDisplay, -} from './question' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts deleted file mode 100644 index c22bc12ac5b..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, createElement } from 'react' -import { createRoot } from 'react-dom/client' -import { describe, expect, it, vi } from 'vitest' -import { - formatQuestionAnswerMessage, - parseQuestionAnswerMessage, - QuestionDisplay, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question' -import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' - -const QUESTIONS: QuestionItem[] = [ - { - type: 'single_select', - prompt: 'How should I handle the duplicates?', - options: [{ id: 'keep_newest', label: 'Keep the newest entry' }], - }, - { - type: 'single_select', - prompt: 'Delete 4 archived workflows?', - options: [ - { id: 'yes', label: 'Delete them' }, - { id: 'no', label: 'Cancel' }, - ], - }, - { - type: 'multi_select', - prompt: 'What time zone should the daily report run in?', - options: [ - { id: 'est', label: 'EST' }, - { id: 'pst', label: 'PST' }, - ], - }, -] - -describe('formatQuestionAnswerMessage', () => { - it('sends a prompt-answer line for a single question', () => { - expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe( - 'How should I handle the duplicates? — Keep the newest entry' - ) - }) - - it('sends one prompt-answer line per question for multi-step batches', () => { - expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe( - 'How should I handle the duplicates? — Keep the newest entry\n' + - 'Delete 4 archived workflows? — Cancel\n' + - 'What time zone should the daily report run in? — EST' - ) - }) -}) - -describe('parseQuestionAnswerMessage', () => { - it('round-trips what formatQuestionAnswerMessage produces', () => { - const answers = ['Keep the newest entry', 'Cancel', 'EST, PST'] - const message = formatQuestionAnswerMessage(QUESTIONS, answers) - expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers) - }) - - it('round-trips a single question', () => { - const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them']) - expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them']) - }) - - it('rejects an unrelated user message (dismissed card, typed something else)', () => { - expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull() - }) - - it('rejects when the line count does not match the question count', () => { - const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B']) - expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull() - }) - - it('rejects when a line pairs with the wrong prompt', () => { - const swapped = - 'Delete 4 archived workflows? — Cancel\n' + - 'How should I handle the duplicates? — Keep the newest entry\n' + - 'What time zone should the daily report run in? — EST' - expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull() - }) - - it('preserves em-dashes inside the answer text', () => { - const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups']) - expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([ - 'newest — but keep backups', - ]) - }) -}) - -describe('QuestionDisplay', () => { - it('reports dismissal when the X hides the card', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - const onDismiss = vi.fn() - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: [QUESTIONS[0]], - onSelect: () => undefined, - onDismiss, - }) - ) - }) - - const dismissButton = Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes('Dismiss') - ) - expect(dismissButton).toBeDefined() - - act(() => dismissButton?.click()) - - expect(onDismiss).toHaveBeenCalledOnce() - expect(container.textContent).not.toContain(QUESTIONS[0].prompt) - - act(() => root.unmount()) - container.remove() - }) - - it('renders multi-select recap answers as separate, spaced rows', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: [QUESTIONS[2]], - answers: ['EST, PST'], - }) - ) - }) - - const answerContainer = container.querySelector('.gap-1') - expect(Array.from(answerContainer?.children ?? []).map((child) => child.textContent)).toEqual([ - 'EST', - 'PST', - ]) - - act(() => root.unmount()) - container.remove() - }) - - it('renders Something else as a placeholder instead of an option', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: [QUESTIONS[0]], - onSelect: () => undefined, - }) - ) - }) - - const input = container.querySelector('input') - expect(input).not.toBeNull() - expect(input?.placeholder).toBe('Something else') - expect(input?.className).toContain('placeholder:text-[var(--text-muted)]') - expect(container.textContent).not.toContain('Something else') - - const optionButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Keep the newest entry' - ) - expect(optionButton).toBeDefined() - - act(() => root.unmount()) - container.remove() - }) - - it('focuses the Something else input when its multi-select checkbox is selected', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: [QUESTIONS[2]], - onSelect: () => undefined, - }) - ) - }) - - const input = container.querySelector('input') - const checkbox = container.querySelector( - 'button[aria-label="Include \\"Something else\\" in the answer"]' - ) - expect(input).not.toBeNull() - expect(checkbox).not.toBeNull() - - act(() => checkbox?.click()) - - expect(checkbox?.dataset.state).toBe('checked') - expect(document.activeElement).toBe(input) - - act(() => checkbox?.focus()) - act(() => checkbox?.click()) - - expect(checkbox?.dataset.state).toBe('unchecked') - - act(() => root.unmount()) - container.remove() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx deleted file mode 100644 index 53811b94c04..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ /dev/null @@ -1,477 +0,0 @@ -'use client' - -import { useRef, useState } from 'react' -import { - ArrowRight, - Button, - Check, - ChevronLeft, - ChevronRight, - checkboxIconVariants, - checkboxVariants, - cn, - X, -} from '@sim/emcn' -import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' - -/** - * Builds the single user message sent after the final question is answered: - * one `Prompt — Answer` line per question, for lone questions too. The uniform - * shape is what lets the chat pair this message back to its question card - * (see parseQuestionAnswerMessage) and render the card as the user turn - * instead of echoing a duplicate bubble. - */ -export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string { - return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n') -} - -/** - * Strictly matches a user message against a question batch's answer format: - * exactly one `Prompt — Answer` line per question, in order. Returns the - * answers, or null when the message is not this batch's answer — a dismissed - * card followed by an unrelated typed message must not match. - */ -export function parseQuestionAnswerMessage( - questions: QuestionItem[], - content: string -): string[] | null { - const lines = content.split('\n') - if (lines.length !== questions.length) return null - const answers: string[] = [] - for (const [i, question] of questions.entries()) { - const prefix = `${question.prompt} — ` - if (!lines[i].startsWith(prefix)) return null - answers.push(lines[i].slice(prefix.length)) - } - return answers -} - -const OPTION_ROW_CLASSES = - 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors' - -/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ -const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' - -/** - * Leading checkbox slot for multi_select rows. Purely presentational — it - * reuses the emcn Checkbox chrome via its exported variants, but the row - * button (or the free-text input) owns the interaction, so nesting a real - * Radix checkbox button inside the row button is avoided. - */ -function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) { - return ( -
- - {checked && ( - - )} - -
- ) -} - -type QuestionPhase = 'active' | 'answered' | 'dismissed' - -interface QuestionDisplayProps { - data: QuestionItem[] - /** - * Answers resolved from the transcript (the paired user message that - * answered this card). When present the card renders as the answered recap - * — it IS the user turn; the paired message bubble is hidden by the chat. - */ - answers?: string[] - /** Sends the combined answer as a user message; undefined renders the div inert. */ - onSelect?: (message: string) => void - /** Reports that the active card was dismissed so its message actions can return. */ - onDismiss?: () => void -} - -/** - * Inline renderer for the `` special tag: a chat-inline div with the - * user input's chrome, the current question's prompt at the top left, dismiss - * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and - * suggested-action option rows beneath, always followed by a custom-answer - * text field whose placeholder reads "Something else". `single_select` - * answers and advances on click (or on submitting typed text); `multi_select` - * rows toggle checkboxes and an option-styled Submit row confirms the step. - * Answering the last question sends one combined user message and collapses - * the div to a question/answer recap. - */ -export function QuestionDisplay({ - data, - answers: transcriptAnswers, - onSelect, - onDismiss, -}: QuestionDisplayProps) { - const freeTextInputRef = useRef(null) - const freeTextCheckboxRef = useRef(null) - const disabled = !onSelect - const [phase, setPhase] = useState('active') - const [step, setStep] = useState(0) - const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => [])) - const [customByStep, setCustomByStep] = useState(() => data.map(() => '')) - const [freeText, setFreeText] = useState('') - // multi_select only: whether the typed "Something else" text is included in - // the answer. Unchecking keeps the text; it just stops counting. - const [customCheckedByStep, setCustomCheckedByStep] = useState(() => - data.map(() => false) - ) - - // The typed text that actually joins a step's answer: multi_select customs - // only count while checked; single_select customs always count. - const customFor = (i: number, customs: string[]): string => - data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') - - const containerClasses = - 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' - - // Transcript answers win over local state: they survive reloads (local - // phase does not) and keep live + rehydrated renders identical. - const localAnswers = - phase === 'answered' - ? data.map((question, i) => - answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep)) - ) - : null - const recapAnswers = transcriptAnswers ?? localAnswers - if (data.length > 0 && recapAnswers) { - return ( -
- {data.map((question, i) => ( -
-

{question.prompt}

-
- {answerPartsForDisplay(question, recapAnswers[i] ?? '').map((answer, answerIndex) => ( -

{answer}

- ))} -
-
- ))} -
- ) - } - - if (data.length === 0 || phase === 'dismissed') return null - - const question = data[step] - const isLast = step === data.length - 1 - const options = question.options - const selected = selectedByStep[step] ?? [] - const isMulti = question.type === 'multi_select' - - const commitCustom = (): string[] => { - const next = [...customByStep] - next[step] = freeText.trim() - setCustomByStep(next) - return next - } - - const goToStep = (next: number) => { - commitCustom() - setStep(next) - const prefill = customByStep[next] ?? '' - setFreeText(prefill) - } - - const finishStep = (selections: string[][], customs: string[]) => { - if (!isLast) { - setStep(step + 1) - const prefill = customs[step + 1] ?? '' - setFreeText(prefill) - return - } - setPhase('answered') - onSelect?.( - formatQuestionAnswerMessage( - data, - data.map((q, i) => answerFor(q, selections[i] ?? [], customFor(i, customs))) - ) - ) - } - - const handleSingleSelect = (label: string) => { - const selections = [...selectedByStep] - selections[step] = [label] - setSelectedByStep(selections) - const customs = [...customByStep] - customs[step] = '' - setCustomByStep(customs) - setFreeText('') - finishStep(selections, customs) - } - - const handleMultiToggle = (label: string) => { - const selections = [...selectedByStep] - const current = selections[step] ?? [] - selections[step] = current.includes(label) - ? current.filter((l) => l !== label) - : [...current, label] - setSelectedByStep(selections) - } - - /** multi_select confirm: commits selections and/or typed text, then advances. */ - const submitMultiStep = () => { - finishStep(selectedByStep, commitCustom()) - } - - /** Sets whether the typed "Something else" text counts — never touches the text. */ - const setCustomChecked = (checked: boolean) => { - const next = [...customCheckedByStep] - next[step] = checked - setCustomCheckedByStep(next) - } - - const toggleCustomChecked = () => { - const isChecked = customCheckedByStep[step] ?? false - setCustomChecked(!isChecked) - if (!isChecked) freeTextInputRef.current?.focus() - } - - /** single_select free-text arrow: the typed text IS the answer. */ - const submitSingleFreeText = () => { - const customs = commitCustom() - const selections = [...selectedByStep] - selections[step] = [] - setSelectedByStep(selections) - finishStep(selections, customs) - } - - const stepAnswered = (i: number): boolean => { - if ((selectedByStep[i]?.length ?? 0) > 0) return true - const text = i === step ? freeText : (customByStep[i] ?? '') - if (text.trim().length === 0) return false - return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true - } - - const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) - - return ( -
-
-

- {question.prompt} -

-
- {data.length > 1 && ( -
- - - {step + 1} of {data.length} - - -
- )} - {!disabled && ( - - )} -
-
-
- {options.map((option, i) => { - const isSelected = selected.includes(option.label) - return ( - - ) - })} -
0 && 'border-t')}> - {isMulti && ( -
- -
- )} - { - if (isMulti) setCustomChecked(true) - }} - onChange={(e) => setFreeText(e.target.value)} - onBlur={(event) => { - if ( - isMulti && - event.relatedTarget !== freeTextCheckboxRef.current && - freeText.trim().length === 0 - ) { - setCustomChecked(false) - } - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.currentTarget.blur() - return - } - if (e.key === 'Enter' && canSubmitStep) { - e.preventDefault() - if (isMulti) { - submitMultiStep() - } else { - submitSingleFreeText() - } - } - }} - aria-label={question.prompt} - className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' - /> - {!isMulti && ( - - )} -
- {isMulti && ( - - )} -
-
- ) -} - -/** - * A step's combined answer: selected option labels in option order, with the - * typed "Something else" entry appended last. single_select carries at most - * one selection, so this collapses to the chosen label or the typed text. - */ -function answerFor(question: QuestionItem, selected: string[], custom: string): string { - const ordered = question.options - .map((option) => option.label) - .filter((label) => selected.includes(label)) - const parts = custom.trim() ? [...ordered, custom.trim()] : ordered - return parts.join(', ') -} - -/** Separates known multi-select labels for the recap without changing the wire answer. */ -function answerPartsForDisplay(question: QuestionItem, answer: string): string[] { - if (question.type !== 'multi_select') return [answer] - - const parts: string[] = [] - let remaining = answer - - for (const option of question.options) { - if (remaining === option.label) { - parts.push(option.label) - remaining = '' - break - } - - const optionPrefix = `${option.label}, ` - if (remaining.startsWith(optionPrefix)) { - parts.push(option.label) - remaining = remaining.slice(optionPrefix.length) - } - } - - if (remaining) parts.push(remaining) - return parts.length > 0 ? parts : [answer] -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index 16dbf2783e2..c45b9199c31 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -6,10 +6,6 @@ export type { MothershipErrorTagData, OptionsTagData, ParsedSpecialContent, - QuestionItem, - QuestionOption, - QuestionTagData, - QuestionType, RuntimeSpecialTagName, UsageUpgradeAction, UsageUpgradeTagData, @@ -21,12 +17,9 @@ export { PendingTagIndicator, parseFileTag, parseJsonTagBody, - parseLastQuestionTag, - parseQuestionTagBody, parseSpecialTags, parseTagAttributes, parseTextTagBody, - QUESTION_TYPES, SpecialTags, USAGE_UPGRADE_ACTIONS, WORKSPACE_RESOURCE_TAG_TYPES, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts deleted file mode 100644 index 638e3c3a747..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - parseQuestionTagBody, - parseSpecialTags, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' - -const SINGLE_SELECT = { - type: 'single_select', - prompt: 'How should I handle the duplicate emails?', - options: [ - { id: 'keep_newest', label: 'Keep the newest entry' }, - { id: 'merge', label: 'Merge fields into one row' }, - ], -} - -const YES_NO = { - type: 'single_select', - prompt: 'Delete 4 archived workflows?', - options: [ - { id: 'yes', label: 'Delete them' }, - { id: 'no', label: 'Cancel' }, - ], -} - -const MULTI_SELECT = { - type: 'multi_select', - prompt: 'Which channels should the report go to?', - options: [ - { id: 'slack', label: 'Slack' }, - { id: 'email', label: 'Email' }, - { id: 'sheet', label: 'Google Sheet' }, - ], -} - -describe('parseQuestionTagBody', () => { - it('normalizes a single object body to a one-element array', () => { - expect(parseQuestionTagBody(JSON.stringify(SINGLE_SELECT))).toEqual([SINGLE_SELECT]) - }) - - it('preserves array order for multi-step bodies', () => { - const parsed = parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])) - expect(parsed).toEqual([SINGLE_SELECT, YES_NO, MULTI_SELECT]) - }) - - it('accepts multi_select questions', () => { - expect(parseQuestionTagBody(JSON.stringify(MULTI_SELECT))).toEqual([MULTI_SELECT]) - }) - - it('rejects single_select without options', () => { - expect(parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Pick' }))).toBe( - null - ) - }) - - it('rejects empty options', () => { - expect( - parseQuestionTagBody(JSON.stringify({ type: 'single_select', prompt: 'Sure?', options: [] })) - ).toBe(null) - }) - - it('rejects the removed text and confirm types', () => { - expect(parseQuestionTagBody(JSON.stringify({ type: 'text', prompt: 'What time zone?' }))).toBe( - null - ) - expect(parseQuestionTagBody(JSON.stringify({ ...YES_NO, type: 'confirm' }))).toBe(null) - }) - - it('strips agent-supplied catch-all options (the card provides its own)', () => { - const withOther = { - ...SINGLE_SELECT, - options: [...SINGLE_SELECT.options, { id: 'other', label: 'Something else' }], - } - expect(parseQuestionTagBody(JSON.stringify(withOther))).toEqual([SINGLE_SELECT]) - }) - - it('rejects a question whose every option is a catch-all', () => { - const onlyOther = { - type: 'single_select', - prompt: 'Pick one', - options: [ - { id: 'a', label: 'Other' }, - { id: 'b', label: 'None of the above' }, - ], - } - expect(parseQuestionTagBody(JSON.stringify(onlyOther))).toBe(null) - }) - - it('rejects an empty prompt', () => { - expect(parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, prompt: ' ' }))).toBe(null) - }) - - it('rejects a malformed option', () => { - expect( - parseQuestionTagBody(JSON.stringify({ ...SINGLE_SELECT, options: [{ id: 'keep_newest' }] })) - ).toBe(null) - }) - - it('rejects an array containing one invalid question', () => { - expect(parseQuestionTagBody(JSON.stringify([SINGLE_SELECT, { type: 'single_select' }]))).toBe( - null - ) - }) - - it('rejects empty arrays and non-JSON bodies', () => { - expect(parseQuestionTagBody('[]')).toBe(null) - expect(parseQuestionTagBody('not json')).toBe(null) - }) -}) - -describe('parseSpecialTags with ', () => { - it('extracts a complete question tag interleaved with text', () => { - const content = `Before the tag. ${JSON.stringify(SINGLE_SELECT)} After the tag.` - const { segments, hasPendingTag } = parseSpecialTags(content, false) - expect(hasPendingTag).toBe(false) - expect(segments).toEqual([ - { type: 'text', content: 'Before the tag. ' }, - { type: 'question', data: [SINGLE_SELECT] }, - { type: 'text', content: ' After the tag.' }, - ]) - }) - - it('extracts a multi-step array body as one segment', () => { - const content = `${JSON.stringify([SINGLE_SELECT, YES_NO, MULTI_SELECT])}` - const { segments } = parseSpecialTags(content, false) - expect(segments).toEqual([{ type: 'question', data: [SINGLE_SELECT, YES_NO, MULTI_SELECT] }]) - }) - - it('flags an unclosed question tag as pending while streaming', () => { - const { segments, hasPendingTag } = parseSpecialTags( - 'Thinking about it. [{"type":"single_sel', - true - ) - expect(hasPendingTag).toBe(true) - expect(segments).toEqual([{ type: 'text', content: 'Thinking about it. ' }]) - }) - - it('strips a trailing partial opening tag while streaming', () => { - const { segments, hasPendingTag } = parseSpecialTags('Let me ask. { - const { segments, hasPendingTag } = parseSpecialTags( - 'Before. {"type":"single_select"} After.', - false - ) - expect(hasPendingTag).toBe(false) - expect(segments).toEqual([ - { type: 'text', content: 'Before. ' }, - { type: 'text', content: ' After.' }, - ]) - }) -}) 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 8791ddd3d05..5fa56531a0b 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 @@ -5,9 +5,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseUserPermissionsContext, mockUseWorkspaceCredential } = vi.hoisted(() => ({ +const { mockUseUserPermissionsContext } = vi.hoisted(() => ({ mockUseUserPermissionsContext: vi.fn(), - mockUseWorkspaceCredential: vi.fn(), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ @@ -18,15 +17,8 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), })) -vi.mock('@/hooks/queries/credentials', () => ({ - useWorkspaceCredential: mockUseWorkspaceCredential, -})) - import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' -import { - parseSpecialTags, - SpecialTags, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' /** * Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the @@ -46,7 +38,6 @@ describe('CredentialDisplay link tag', () => { beforeEach(() => { vi.clearAllMocks() mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) - mockUseWorkspaceCredential.mockReturnValue({ data: null }) }) it('does not render an anchor for a javascript: scheme value', () => { @@ -72,17 +63,17 @@ describe('CredentialDisplay link tag', () => { }) it('renders a working link for a real http(s) connect URL', () => { - const url = 'https://sim.test/api/auth/oauth2/authorize?providerId=google-drive' + const url = 'https://github.com/login/oauth/authorize?client_id=abc&scope=repo' const { container, root } = renderCredentialLink({ type: 'link', - provider: 'google-drive', + provider: 'github', value: url, }) const link = container.querySelector('a') expect(link).not.toBeNull() expect(link?.getAttribute('href')).toBe(url) - expect(container.textContent).toContain('Connect Google Drive') + expect(container.textContent).toContain('Connect github') act(() => root.unmount()) }) @@ -97,53 +88,4 @@ describe('CredentialDisplay link tag', () => { expect(container.querySelector('a')).toBeNull() act(() => root.unmount()) }) - - it('labels a reconnect URL with the credential display name', () => { - mockUseWorkspaceCredential.mockReturnValue({ - data: { id: 'cred-1', displayName: "Justin's Gmail" }, - }) - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: - 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', - }) - - expect(mockUseWorkspaceCredential).toHaveBeenCalledWith('cred-1') - expect(container.textContent).toContain("Reconnect Justin's Gmail") - act(() => root.unmount()) - }) - - it('falls back to the integration name while the reconnect credential is unresolved', () => { - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: - 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&workspaceId=ws-1&credentialId=cred-1', - }) - - expect(container.textContent).toContain('Reconnect Gmail') - act(() => root.unmount()) - }) -}) - -describe('parseSpecialTags sim_key placeholder', () => { - it('accepts a value-less {"type":"sim_key"} tag as a credential segment', () => { - const { segments } = parseSpecialTags('{"type":"sim_key"}', false) - const credential = segments.find((s) => s.type === 'credential') - expect(credential).toEqual({ type: 'credential', data: { type: 'sim_key' } }) - }) - - it('still accepts the legacy {"redacted":true} form as a value-less sim_key placeholder', () => { - const { segments } = parseSpecialTags( - '{"type":"sim_key","redacted":true}', - false - ) - const credential = segments.find((s) => s.type === 'credential') - expect(credential?.type).toBe('credential') - if (credential?.type === 'credential') { - expect(credential.data.type).toBe('sim_key') - expect(credential.data.value).toBeUndefined() - } - }) }) 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 a566cd3e9c2..50eb261d7a9 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 @@ -20,16 +20,13 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' -import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' -import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { usePersonalEnvironment, useSavePersonalEnvironment, @@ -81,6 +78,7 @@ export interface CredentialTagData { value?: string type: CredentialTagType provider?: string + redacted?: boolean /** * Env-var key name to save the pasted secret under (secret_input only), * e.g. "OPENAI_API_KEY". @@ -102,30 +100,6 @@ export interface FileTagData { content: string } -export const QUESTION_TYPES = ['single_select', 'multi_select'] as const - -export type QuestionType = (typeof QUESTION_TYPES)[number] - -export interface QuestionOption { - id: string - label: string -} - -/** - * One question in a `` tag: a single_select or multi_select with at - * least one real option. The card always appends its own free-text "Something - * else" row, so agent-supplied catch-all options ("Other", "Something else", - * ...) are stripped during parsing. - */ -export interface QuestionItem { - type: QuestionType - prompt: string - options: QuestionOption[] -} - -/** Normalized `` payload: single-object bodies become a one-element array. */ -export type QuestionTagData = QuestionItem[] - export const WORKSPACE_RESOURCE_TAG_TYPES = ['workflow', 'table', 'file'] as const export type WorkspaceResourceTagType = (typeof WORKSPACE_RESOURCE_TAG_TYPES)[number] @@ -145,7 +119,6 @@ export type ContentSegment = | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } - | { type: 'question'; data: QuestionTagData } export type RuntimeSpecialTagName = | 'thinking' @@ -154,7 +127,6 @@ export type RuntimeSpecialTagName = | 'mothership-error' | 'file' | 'workspace_resource' - | 'question' export interface ParsedSpecialContent { segments: ContentSegment[] @@ -168,7 +140,6 @@ const RUNTIME_SPECIAL_TAG_NAMES = [ 'mothership-error', 'file', 'workspace_resource', - 'question', ] as const const SPECIAL_TAG_NAMES = [ @@ -178,7 +149,6 @@ const SPECIAL_TAG_NAMES = [ 'credential', 'mothership-error', 'workspace_resource', - 'question', ] as const function isRecord(value: unknown): value is Record { @@ -225,11 +195,7 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } - // A sim_key chip is platform-filled: the model only marks where the workspace - // API key belongs (it never holds the value) and Sim injects it from the tool - // result, so the tag is valid with or without a `value`. Every other rendered - // type (e.g. link) needs a string value to render. - if (value.type === 'sim_key') return true + if (value.redacted === true) return value.value === undefined || typeof value.value === 'string' return typeof value.value === 'string' } @@ -260,83 +226,6 @@ function isWorkspaceResourceTagData(value: unknown): value is WorkspaceResourceT return id.length > 0 } -function isQuestionOption(value: unknown): value is QuestionOption { - if (!isRecord(value)) return false - return typeof value.id === 'string' && typeof value.label === 'string' -} - -/** - * Catch-all labels the agent must not supply as options — the card renders - * its own free-text "Something else" row. Matching options are stripped; a - * question left with no real options is invalid. - */ -const SELF_PROVIDED_OPTION_LABELS = new Set([ - 'other', - 'others', - 'something else', - 'none of the above', - 'none of these', -]) - -function isQuestionItem(value: unknown): value is QuestionItem { - if (!isRecord(value)) return false - if ( - typeof value.type !== 'string' || - !(QUESTION_TYPES as readonly string[]).includes(value.type) - ) { - return false - } - if (typeof value.prompt !== 'string' || value.prompt.trim().length === 0) return false - return ( - Array.isArray(value.options) && - value.options.length > 0 && - value.options.every(isQuestionOption) - ) -} - -/** Strips agent-supplied catch-all options; null when none remain. */ -function sanitizeQuestionItem(item: QuestionItem): QuestionItem | null { - const options = item.options.filter( - (option) => !SELF_PROVIDED_OPTION_LABELS.has(option.label.trim().toLowerCase()) - ) - if (options.length === 0) return null - return options.length === item.options.length ? item : { ...item, options } -} - -/** - * Parses a `` tag body. Accepts a single question object or a - * non-empty array of them; single objects are normalized to a one-element - * array so the renderer only handles the array shape. - */ -/** - * Extracts the last complete `` tag payload from raw message - * content. Used by the chat list to pair an assistant question card with the - * user message that answered it. - */ -export function parseLastQuestionTag(content: string): QuestionTagData | null { - const matches = content.match(/([\s\S]*?)<\/question>/g) - if (!matches || matches.length === 0) return null - const last = matches[matches.length - 1] - return parseQuestionTagBody(last.slice(''.length, -''.length)) -} - -export function parseQuestionTagBody(body: string): QuestionTagData | null { - try { - const parsed = JSON.parse(body) as unknown - const items = Array.isArray(parsed) ? parsed : [parsed] - if (items.length === 0 || !items.every(isQuestionItem)) return null - const sanitized: QuestionItem[] = [] - for (const item of items) { - const clean = sanitizeQuestionItem(item) - if (!clean) return null - sanitized.push(clean) - } - return sanitized - } catch { - return null - } -} - export function parseJsonTagBody( body: string, isExpectedShape: (value: unknown) => value is T @@ -385,7 +274,6 @@ function parseSpecialTagData( | { type: 'credential'; data: CredentialTagData } | { type: 'mothership-error'; data: MothershipErrorTagData } | { type: 'workspace_resource'; data: WorkspaceResourceTagData } - | { type: 'question'; data: QuestionTagData } | null { if (tagName === 'thinking') { const content = parseTextTagBody(body) @@ -417,11 +305,6 @@ function parseSpecialTagData( return data ? { type: 'workspace_resource', data } : null } - if (tagName === 'question') { - const data = parseQuestionTagBody(body) - return data ? { type: 'question', data } : null - } - return null } @@ -514,10 +397,7 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS interface SpecialTagsProps { segment: Exclude - /** Transcript-derived answers for this message's question card (renders the recap). */ - questionAnswers?: string[] onOptionSelect?: (id: string) => void - onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void } @@ -527,9 +407,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, - questionAnswers, onOptionSelect, - onQuestionDismiss, onWorkspaceResourceSelect, }: SpecialTagsProps) { switch (segment.type) { @@ -545,15 +423,6 @@ export function SpecialTags({ return case 'workspace_resource': return - case 'question': - return ( - - ) default: return null } @@ -865,62 +734,36 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } -function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { +function CredentialDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() - // A connect URL carrying a credentialId re-authorizes that existing - // credential in place (reconnect) rather than creating a new one. - const reconnectCredentialId = useMemo(() => { - if (!data.value) return undefined - try { - return new URL(data.value).searchParams.get('credentialId') ?? undefined - } catch { - return undefined - } - }, [data.value]) - const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) - - // Connecting a credential mutates the workspace — hide it from read-only members. - if (!data.provider || !canEdit) return null - // The connect link value comes from the streamed model output, so only - // render it as a clickable link when it resolves to a real http(s) URL. - if (!data.value || !isSafeHttpUrl(data.value)) return null - const Icon = getCredentialIcon(data.provider) ?? LockIcon - const integrationName = - getServiceConfigByProviderId(data.provider)?.name ?? - OAUTH_PROVIDERS[data.provider.toLowerCase()]?.name ?? - data.provider - const label = reconnectCredentialId - ? `Reconnect ${reconnectCredential?.displayName ?? integrationName}` - : `Connect ${integrationName}` - return ( - - {createElement(Icon, { className: 'size-[16px] shrink-0' })} - {label} - - - ) -} - -function CredentialDisplay({ data }: { data: CredentialTagData }) { if (data.type === 'secret_input') { return } if (data.type === 'link') { - return + // Connecting a credential mutates the workspace — hide it from read-only members. + if (!data.provider || !canEdit) return null + // The connect link value comes from the streamed model output, so only + // render it as a clickable link when it resolves to a real http(s) URL. + if (!data.value || !isSafeHttpUrl(data.value)) return null + const Icon = getCredentialIcon(data.provider) ?? LockIcon + return ( + + {createElement(Icon, { className: 'size-[16px] shrink-0' })} + Connect {data.provider} + + + ) } if (data.type === 'sim_key') { - // SecretReveal masks itself when there's no value, so a value-less tag (the - // model's placeholder / persisted form) renders masked and a Sim-filled tag - // reveals the key + copy button — no separate "redacted" flag needed. - return + return } return null @@ -944,7 +787,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { : 'Only the workspace owner can manage this workspace’s usage limits.' return ( -
+
{ - const args: Record = {} - if (!entry.parameters || typeof entry.parameters !== 'object') return args - const properties = (entry.parameters as { properties?: unknown }).properties - if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args - - for (const [key, rawSchema] of Object.entries(properties)) { - if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue - const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown } - if (schema.default !== undefined) { - args[key] = schema.default - } else if (Array.isArray(schema.enum) && schema.enum.length > 0) { - args[key] = schema.enum[0] - } else if (schema.type === 'boolean') { - args[key] = true - } else if (schema.type === 'object') { - args[key] = {} - } - } - return args -} - -function toolEnvelope( - seq: number, - payload: Record, - agentId = 'deploy' -): PersistedStreamEventEnvelope { - return { - v: 1, - seq, - ts: new Date(seq).toISOString(), - stream: { streamId: 'stream-1', cursor: String(seq) }, - type: 'tool', - payload, - scope: { - lane: 'subagent', - spanId: `${agentId}-span`, - parentSpanId: 'main', - agentId, - }, - } as PersistedStreamEventEnvelope -} - describe('parseBlocks span-identity tree', () => { - it('refines a completed credential rename with its previous and new names', () => { - const segments = parseBlocks([ - { - type: 'tool_call', - toolCall: { - id: 'rename-credential', - name: 'manage_credential', - status: 'success', - params: { operation: 'rename', displayName: 'Production Stripe' }, - result: { - success: true, - output: { - previousDisplayName: 'Stripe', - displayName: 'Production Stripe', - }, - }, - }, - timestamp: 1, - }, - ]) - - expect(segments).toHaveLength(1) - const group = segments[0] - if (group.type !== 'agent_group') throw new Error('expected mothership group') - const tool = group.items[0] - if (tool?.type !== 'tool') throw new Error('expected credential tool') - expect(tool.data.displayTitle).toBe('Renamed Stripe to Production Stripe') - }) - it('nests a deploy subagent inside the workflow subagent that spawned it', () => { const blocks: ContentBlock[] = [ subagentStart('workflow', 'S1', 'main'), @@ -317,65 +231,6 @@ describe('parseBlocks span-identity tree', () => { expect(nested.group.agentName).toBe('file') }) - it('suppresses subagent thinking while keeping the delegating spinner', () => { - const blocks: ContentBlock[] = [ - subagentStart('workflow', 'S1', 'main'), - { - type: 'subagent_thinking', - content: 'reasoning about the fix', - spanId: 'S1', - subagent: 'workflow', - timestamp: 2, - }, - ] - - const segments = parseBlocks(blocks) - expect(segments).toHaveLength(1) - if (segments[0].type !== 'agent_group') throw new Error('expected workflow group') - expect(segments[0].items).toEqual([]) - // Suppressed reasoning does not count as visible output or clear activity. - expect(segments[0].isDelegating).toBe(true) - }) - - it('does not create visible output when thinking arrives before its subagent start', () => { - const blocks: ContentBlock[] = [ - { - type: 'subagent_thinking', - content: 'early reasoning', - spanId: 'S1', - parentSpanId: 'main', - subagent: 'workflow', - timestamp: 1, - }, - subagentStart('workflow', 'S1', 'main'), - ] - - const segments = parseBlocks(blocks) - const group = segments.find((s) => s.type === 'agent_group') - if (!group || group.type !== 'agent_group') throw new Error('expected workflow group') - expect(group.agentName).toBe('workflow') - expect(group.items).toEqual([]) - }) - - it('renders only assistant text after suppressed subagent thinking', () => { - const blocks: ContentBlock[] = [ - subagentStart('workflow', 'S1', 'main'), - { - type: 'subagent_thinking', - content: 'planning', - spanId: 'S1', - subagent: 'workflow', - timestamp: 2, - }, - { type: 'subagent_text', content: 'done', spanId: 'S1', subagent: 'workflow', timestamp: 3 }, - ] - - const segments = parseBlocks(blocks) - if (segments[0].type !== 'agent_group') throw new Error('expected workflow group') - expect(segments[0].items).toEqual([{ type: 'text', content: 'done' }]) - expect(segments[0].isDelegating).toBe(false) - }) - it('falls back to legacy flat grouping when blocks have no span identity', () => { const blocks: ContentBlock[] = [ { type: 'subagent', content: 'workflow', parentToolCallId: 'tc-1', timestamp: 1 }, @@ -440,128 +295,6 @@ describe('completed tool titles', () => { ) }) - it('renders the completed deployment action and deployment type', () => { - expect( - firstToolTitle([ - { - type: 'tool_call', - toolCall: { - id: 'undeploy-api', - name: 'deploy_api', - status: 'success', - params: { action: 'undeploy' }, - }, - timestamp: 1, - }, - ]) - ).toBe('Undeployed API') - - expect(firstToolTitle([mainToolCall('deploy-mcp', 'deploy_mcp')])).toBe('Deployed MCP tool') - }) - - it('renders Compared after the full diff_workflows wire lifecycle succeeds', () => { - const model = createTurnModel() - reduceEvent( - model, - toolEnvelope(1, { - phase: 'call', - toolCallId: 'diff-1', - toolName: 'diff_workflows', - arguments: { ref1: 'live', ref2: 'draft' }, - }) - ) - reduceEvent( - model, - toolEnvelope(2, { - phase: 'result', - toolCallId: 'diff-1', - toolName: 'diff_workflows', - success: true, - status: 'success', - output: { differences: [] }, - }) - ) - - expect(firstToolTitle(modelToContentBlocks(model))).toBe('Compared workflows') - }) - - it('humanizes an internal read target through the full wire lifecycle', () => { - const model = createTurnModel() - reduceEvent( - model, - toolEnvelope( - 1, - { - phase: 'call', - toolCallId: 'read-oauth-integrations', - toolName: 'read', - arguments: { path: 'environment/oauth-integrations.json' }, - }, - 'auth' - ) - ) - reduceEvent( - model, - toolEnvelope( - 2, - { - phase: 'result', - toolCallId: 'read-oauth-integrations', - toolName: 'read', - success: true, - status: 'success', - output: {}, - }, - 'auth' - ) - ) - - expect(firstToolTitle(modelToContentBlocks(model))).toBe('Read OAuth integrations') - }) - - it('renders the completed title through the full wire lifecycle for every visible tool', () => { - const hiddenToolNames = getHiddenToolNames() - const failures: string[] = [] - - for (const [toolName, entry] of Object.entries(TOOL_CATALOG)) { - // Internal subagent dispatches become agent groups, and hidden plumbing - // is intentionally suppressed; neither produces a visible tool row. - if (entry.internal || hiddenToolNames.has(toolName)) continue - - const args = representativeToolArgs(entry) - const model = createTurnModel() - reduceEvent( - model, - toolEnvelope(1, { - phase: 'call', - toolCallId: `${toolName}-1`, - toolName, - arguments: args, - }) - ) - reduceEvent( - model, - toolEnvelope(2, { - phase: 'result', - toolCallId: `${toolName}-1`, - toolName, - success: true, - status: 'success', - output: {}, - }) - ) - - const presentTitle = getToolDisplayTitle(toolName, args) - const expectedTitle = getToolStatusDisplayTitle(presentTitle, 'success') - const actualTitle = firstToolTitle(modelToContentBlocks(model)) - if (actualTitle !== expectedTitle) { - failures.push(`${toolName}: expected ${expectedTitle}, received ${actualTitle}`) - } - } - - expect(failures).toEqual([]) - }) - it('keeps present tense while executing and on error', () => { expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs') expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs') @@ -569,6 +302,32 @@ describe('completed tool titles', () => { }) describe('narration text seams', () => { + it('inserts a space between glued consecutive blocks', () => { + const blocks: ContentBlock[] = [ + subagentStart('research', 'S1', 'main'), + { + type: 'subagent_thinking', + content: 'that triggered it.', + spanId: 'S1', + subagent: 'research', + timestamp: 2, + }, + { + type: 'subagent_text', + content: 'The failing block is X.', + spanId: 'S1', + subagent: 'research', + timestamp: 3, + }, + ] + const segments = parseBlocks(blocks) + const group = segments.find((s) => s.type === 'agent_group') + if (!group || group.type !== 'agent_group') throw new Error('expected group') + const text = group.items.find((i) => i.type === 'text') + if (!text || text.type !== 'text') throw new Error('expected text') + expect(text.content).toBe('that triggered it. The failing block is X.') + }) + it('never inserts a space into a segment split mid-word or mid-URL', () => { const seam = (first: string, second: string): string => { const blocks: ContentBlock[] = [ @@ -627,169 +386,3 @@ describe('narration text seams', () => { expect(text.content).toBe('first sentence. second sentence.') }) }) - -describe('shouldShowTrailingThinking', () => { - it('shows one turn-level indicator while an open subagent waits between completed steps', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'agent_group', - }) - ).toBe(true) - }) - - it('stays hidden while a chunk is rendering or before the stream becomes idle', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: true, - hasExecutingTool: false, - lastSegmentType: 'text', - }) - ).toBe(false) - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: false, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'agent_group', - }) - ).toBe(false) - }) - - it('does not duplicate an executing tool row or survive a stopped turn', () => { - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: true, - lastSegmentType: 'agent_group', - }) - ).toBe(false) - expect( - shouldShowTrailingThinking({ - isStreaming: true, - isStreamIdle: true, - isRenderingStream: false, - hasExecutingTool: false, - lastSegmentType: 'stopped', - }) - ).toBe(false) - }) -}) - -describe('parseBlocks legacy — thinking between top-level tools', () => { - it('keeps consecutive mothership tools in one group across intervening thinking', () => { - const blocks: ContentBlock[] = [ - { type: 'thinking', content: 'planning the search', timestamp: 1 }, - mainToolCall('t1', 'grep'), - { type: 'thinking', content: 'now read the workflow', timestamp: 1 }, - mainToolCall('t2', 'read'), - mainToolCall('t3', 'read'), - ] - const segments = parseBlocks(blocks) - const groups = segments.filter((s) => s.type === 'agent_group') - expect(groups).toHaveLength(1) - if (groups[0].type !== 'agent_group') throw new Error('expected group') - expect(groups[0].agentName).toBe('mothership') - expect(groups[0].items).toHaveLength(3) - }) - - it('still splits the mothership run on real main text', () => { - const blocks: ContentBlock[] = [ - mainToolCall('t1', 'grep'), - mainText('Here is what I found so far.'), - mainToolCall('t2', 'read'), - ] - const segments = parseBlocks(blocks) - const groups = segments.filter((s) => s.type === 'agent_group') - expect(groups).toHaveLength(2) - }) - - it('does not let main thinking affect subagent lane grouping', () => { - const blocks: ContentBlock[] = [ - { type: 'subagent', content: 'workflow', parentToolCallId: 'd1', timestamp: 1 }, - { type: 'subagent_text', content: 'working', parentToolCallId: 'd1', timestamp: 1 }, - { type: 'thinking', content: 'main reasoning', timestamp: 1 }, - { type: 'subagent_text', content: 'later chunk with no lane tag', timestamp: 1 }, - ] - const segments = parseBlocks(blocks) - const groups = segments.filter((s) => s.type === 'agent_group') - expect(groups).toHaveLength(1) - if (groups[0].type !== 'agent_group') throw new Error('expected group') - // Thinking is absent from persistence, so it cannot split the live lane. - expect(groups[0].items).toHaveLength(1) - expect(groups[0].items[0]).toEqual({ - type: 'text', - content: 'workinglater chunk with no lane tag', - }) - }) - - it('suppresses subagent thinking inside the legacy lane', () => { - const blocks: ContentBlock[] = [ - { type: 'subagent', content: 'workflow', parentToolCallId: 'd1', timestamp: 1 }, - { - type: 'subagent_thinking', - content: 'legacy reasoning', - parentToolCallId: 'd1', - timestamp: 2, - }, - { type: 'subagent_text', content: 'output', parentToolCallId: 'd1', timestamp: 3 }, - ] - const segments = parseBlocks(blocks) - const groups = segments.filter((s) => s.type === 'agent_group') - expect(groups).toHaveLength(1) - if (groups[0].type !== 'agent_group') throw new Error('expected group') - expect(groups[0].items).toEqual([{ type: 'text', content: 'output' }]) - }) -}) - -describe('assistantMessageHasVisibleExecutingTool', () => { - it('does not treat an open subagent lane as an executing tool row', () => { - expect(assistantMessageHasVisibleExecutingTool([subagentStart('workflow', 'S1', 'main')])).toBe( - false - ) - }) - - it('keeps a visible executing tool as active work', () => { - const blocks: ContentBlock[] = [ - subagentStart('workflow', 'S1', 'main'), - { - type: 'tool_call', - toolCall: { id: 't1', name: 'grep', status: 'executing', calledBy: 'workflow' }, - spanId: 'S1', - timestamp: 3, - }, - ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(true) - }) - - it('does not let open parallel lanes suppress the single turn-level indicator', () => { - const blocks: ContentBlock[] = [ - subagentStart('workflow', 'S1', 'main'), - subagentStart('search', 'S2', 'main'), - ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) - }) - - it('ignores the executing dispatch tool represented by its subagent lane', () => { - const blocks: ContentBlock[] = [ - { - type: 'tool_call', - toolCall: { id: 'dispatch-1', name: 'workspace_file', status: 'executing' }, - timestamp: 1, - }, - { - ...subagentStart('file', 'S1', 'main'), - parentToolCallId: 'dispatch-1', - }, - ] - expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false) - }) -}) 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 9d8f67067f8..dadd78aab6e 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 @@ -1,13 +1,13 @@ 'use client' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Read as ReadTool, WorkspaceFile } 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' import { + getToolCompletedTitle, getToolDisplayTitle, - getToolStatusDisplayTitle, humanizeToolName, } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -18,7 +18,6 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' -const STREAM_IDLE_DELAY_MS = 1_500 interface TextSegment { type: 'text' @@ -48,56 +47,6 @@ interface StoppedSegment { type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment -function getAgentGroupActivityKey(items: AgentGroupItem[]): string { - return items - .map((item) => { - if (item.type === 'text') { - return `text:${item.content.length}` - } - if (item.type === 'tool') { - return [ - 'tool', - item.data.id, - item.data.status, - item.data.displayTitle, - item.data.streamingArgs?.length ?? 0, - ].join(':') - } - return [ - 'agent', - item.group.id, - item.group.isDelegating ? 1 : 0, - item.group.isOpen ? 1 : 0, - getAgentGroupActivityKey(item.group.items), - ].join(':') - }) - .join('|') -} - -/** - * Compact identity for what the transcript is visibly rendering. Main-lane - * reasoning and other suppressed blocks intentionally do not affect it, while - * activity in every nested/parallel lane does. - */ -function getVisibleStreamActivityKey(segments: MessageSegment[]): string { - return segments - .map((segment) => { - if (segment.type === 'text') return `text:${segment.id}:${segment.content.length}` - if (segment.type === 'options') { - return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}` - } - if (segment.type === 'stopped') return 'stopped' - return [ - 'agent', - segment.id, - segment.isDelegating ? 1 : 0, - segment.isOpen ? 1 : 0, - getAgentGroupActivityKey(segment.items), - ].join(':') - }) - .join('||') -} - const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS)) /** @@ -151,17 +100,6 @@ function getOverrideDisplayTitle(tc: NonNullable): str if (tc.name === ReadTool.id || tc.name === 'respond' || tc.name.endsWith('_respond')) { return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.params)?.text } - if (tc.name === 'manage_credential' && tc.params?.operation === 'rename') { - const output = tc.result?.output - const result = output && typeof output === 'object' ? (output as Record) : null - const previousDisplayName = result?.previousDisplayName - if (typeof previousDisplayName === 'string' && previousDisplayName.trim()) { - return getToolDisplayTitle(tc.name, { - ...tc.params, - previousDisplayName: previousDisplayName.trim(), - }) - } - } return undefined } @@ -169,7 +107,10 @@ function toToolData(tc: NonNullable): ToolCallData { const overrideDisplayTitle = getOverrideDisplayTitle(tc) const resolvedTitle = overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params) - const displayTitle = getToolStatusDisplayTitle(resolvedTitle, tc.status) + const displayTitle = + tc.status === 'success' + ? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle) + : resolvedTitle return { id: tc.id, @@ -196,18 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment { } } +type NarrationChannel = 'thinking' | 'assistant' + /** * Appends narration content to a group, merging into the previous text item. - * Streamed chunks and resume legs are concatenated verbatim, so a token split - * like `v2.` + `1` is never mutated. + * When a thinking run and a text run meet, their contents can glue together + * without any whitespace at the seam. The merge repairs only that semantic + * channel transition, and only at an unambiguous sentence boundary — trailing + * punctuation meeting a fresh alphanumeric start. Same-channel continuations + * (streamed chunks of one run, resume legs) are concatenated verbatim, so a + * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the + * caller's per-parse tracker of each group's most recent narration channel. */ -function appendTextItem(group: AgentGroupSegment, content: string): void { +function appendTextItem( + group: AgentGroupSegment, + content: string, + channel: NarrationChannel, + lastChannelByGroup: Map +): void { const lastItem = group.items[group.items.length - 1] if (lastItem?.type === 'text') { - lastItem.content += content + const isChannelSeam = lastChannelByGroup.get(group) !== channel + const needsSpace = + isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content) + lastItem.content += (needsSpace ? ' ' : '') + content } else { group.items.push({ type: 'text', content }) } + lastChannelByGroup.set(group, channel) } /** @@ -221,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void { function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsBySpanId = new Map() + const lastNarrationChannel = new Map() // Stable per-run counters for React keys. The Nth top-level text run / Nth // mothership group keeps the same key across re-parses (text runs and groups // are append-only at the top level), so React never remounts the streaming @@ -250,8 +208,8 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // Top-level (mothership) tool calls render in a collapsible group. Reuse that // group only while it is still the most recent segment so consecutive tools - // stay together; once another visible segment (main text or a spawned - // subagent) breaks the run, the next tool opens a fresh group below it + // stay together; once any other segment (main text, a spawned subagent, + // thinking, etc.) breaks the run, the next tool opens a fresh group below it // instead of jumping back up into the original one. This keeps the mothership's // tools and prose interleaved in the order they actually happened. const ensureMothership = (): AgentGroupSegment => { @@ -316,12 +274,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - // Thinking is intentionally absent from the transcript. Ignore both lanes - // so rollout-skewed or replayed streams cannot surface reasoning or affect - // layout differently from the persisted message, which strips it. - if (block.type === 'thinking' || block.type === 'subagent_thinking') continue - - if (block.type === 'subagent_text') { + if (block.type === 'subagent_text' || block.type === 'subagent_thinking') { if (!block.content || !block.spanId) continue let g = groupsBySpanId.get(block.spanId) // Out-of-order safety: content can arrive before its subagent-start block @@ -332,10 +285,19 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { } if (!g) continue g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) continue } + // Main-agent thinking is intentionally not rendered. The reasoning is still + // reduced and persisted upstream — this is a display-only omission. + if (block.type === 'thinking') continue + if (block.type === 'text') { if (!block.content) continue if (block.subagent && block.spanId) { @@ -344,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId) if (g) { g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } @@ -372,8 +334,8 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // Show the working/delegating spinner from span open until the agent // emits its first content or tool (or ends). The legacy path derived this // from the dispatch tool_call, which the span path absorbs, so we set it - // here. It is cleared in the subagent_text, scoped text, tool_call, and - // subagent_end branches; suppressed thinking leaves it unchanged. + // here. It is cleared in the subagent_text/subagent_thinking, scoped text, + // tool_call, and subagent_end branches. g.isDelegating = true g.isOpen = true continue @@ -473,15 +435,8 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const segments: MessageSegment[] = [] const groupsByKey = new Map() + const lastNarrationChannel = new Map() let activeGroupKey: string | null = null - // Run-ordinal keys, mirroring parseBlocksWithSpanTree. A turn starts in this - // parser and flips to the span-tree parser when the first spanId-carrying - // block arrives; segments that exist in both must keep the SAME React key - // across that flip or their subtrees remount mid-stream (group re-expands, - // text re-fades). Block-index text keys and position-based mothership ids - // diverge from the span-tree scheme; run ordinals match it. - let textRun = 0 - let mothershipRun = 0 const groupKey = (name: string, parentToolCallId: string | undefined) => parentToolCallId ? `${name}:${parentToolCallId}` : `${name}:legacy` @@ -508,14 +463,9 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { type: 'agent_group', // Canonical key = the dispatch tool call id, identical to the span-tree // parser, so a transcript that gains span ids (or a DB reload) keeps the - // same React key and never remounts. The mothership group uses the same - // run-ordinal id as the span-tree parser for the same reason. Orphans - // (no dispatch tool, not mothership) keep the position-based legacy id. - id: parentToolCallId - ? `agent-${parentToolCallId}` - : name === 'mothership' - ? `agent-mothership-${mothershipRun++}` - : `agent-${key}-${segments.length}`, + // same React key and never remounts. Orphans (no dispatch tool) keep the + // position-based legacy id. + id: parentToolCallId ? `agent-${parentToolCallId}` : `agent-${key}-${segments.length}`, agentName: name, agentLabel: resolveAgentLabel(name), items: [], @@ -552,16 +502,25 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - // See the span-tree parser: thinking is neither visible nor allowed to - // influence grouping because it is absent from persisted transcripts. - if (block.type === 'thinking' || block.type === 'subagent_thinking') continue - - if (block.type === 'subagent_text') { + if (block.type === 'subagent_text' || block.type === 'subagent_thinking') { if (!block.content) continue const g = findGroupForSubagentChunk(block.parentToolCallId) if (!g) continue g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem( + g, + block.content, + block.type === 'subagent_thinking' ? 'thinking' : 'assistant', + lastNarrationChannel + ) + continue + } + + if (block.type === 'thinking') { + // Main-agent thinking is not rendered, but it still breaks open subagent + // lanes so later chunks don't merge across it (display-only omission). + if (!block.content?.trim()) continue + flushLanes() continue } @@ -571,7 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId)) if (g) { g.isDelegating = false - appendTextItem(g, block.content) + appendTextItem(g, block.content, 'assistant', lastNarrationChannel) continue } } @@ -580,7 +539,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { if (last?.type === 'text') { last.content += block.content } else { - segments.push({ type: 'text', id: `text-${textRun++}`, content: block.content }) + segments.push({ type: 'text', id: `text-${i}`, content: block.content }) } continue } @@ -707,25 +666,6 @@ export function assistantMessageHasRenderableContent( return segments.length > 0 } -/** True when the transcript is already rendering an executing tool row. */ -export function assistantMessageHasVisibleExecutingTool(blocks: ContentBlock[]): boolean { - const subagentDispatchCallIds = new Set() - for (const block of blocks) { - if (block.type === 'subagent' && block.parentToolCallId) { - subagentDispatchCallIds.add(block.parentToolCallId) - } - } - - return blocks.some((block) => { - const toolCall = block.toolCall - if (!toolCall || toolCall.status !== 'executing') return false - if (isHiddenToolCall(toolCall.name)) return false - if (toolCall.name === ReadTool.id && isToolResultRead(toolCall.params)) return false - if (SUBAGENT_KEYS.has(toolCall.name)) return false - return !subagentDispatchCallIds.has(toolCall.id) - }) -} - export function shouldSmoothTextSegment({ isStreaming, segmentIndex, @@ -738,36 +678,11 @@ export function shouldSmoothTextSegment({ return isStreaming && segmentIndex === segmentCount - 1 } -export function shouldShowTrailingThinking({ - isStreaming, - isStreamIdle, - isRenderingStream, - hasExecutingTool, - lastSegmentType, -}: { - isStreaming: boolean - isStreamIdle: boolean - isRenderingStream: boolean - hasExecutingTool: boolean - lastSegmentType?: 'text' | 'agent_group' | 'options' | 'stopped' -}): boolean { - return ( - isStreaming && - isStreamIdle && - !isRenderingStream && - !hasExecutingTool && - lastSegmentType !== 'stopped' - ) -} - interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string isStreaming: boolean - /** Transcript-derived answers for this message's question card (renders the recap). */ - questionAnswers?: string[] onOptionSelect?: (id: string) => void - onQuestionDismiss?: () => void onPhaseChange?: (phase: MessagePhase) => void } @@ -775,9 +690,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, - questionAnswers, onOptionSelect, - onQuestionDismiss, onPhaseChange, }: MessageContentProps) { const { onWorkspaceResourceSelect } = useChatSurface() @@ -787,11 +700,6 @@ function MessageContentInner({ const handleTrailingRevealChange = useCallback((revealing: boolean) => { setTrailingRevealing(revealing) }, []) - const [trailingStreamActivity, setTrailingStreamActivity] = useState(false) - const handleTrailingStreamActivityChange = useCallback((active: boolean) => { - setTrailingStreamActivity(active) - }, []) - const [isStreamIdle, setIsStreamIdle] = useState(false) const segments: MessageSegment[] = parsed.length > 0 @@ -799,21 +707,6 @@ function MessageContentInner({ : fallbackContent?.trim() ? [{ type: 'text' as const, id: 'text-fallback', content: fallbackContent }] : [] - const visibleStreamActivityKey = getVisibleStreamActivityKey(segments) - - // Every visible stream update restarts the quiet-period clock. A layout - // effect clears an already-visible indicator before paint, so a chunk from - // any parallel lane hides the one turn-level loader without a stale flash. - useLayoutEffect(() => { - if (!isStreaming) { - setIsStreamIdle(false) - return - } - - setIsStreamIdle(false) - const timeout = setTimeout(() => setIsStreamIdle(true), STREAM_IDLE_DELAY_MS) - return () => clearTimeout(timeout) - }, [visibleStreamActivityKey, isStreaming]) const lastSegment = segments[segments.length - 1] const hasTrailingTextSegment = lastSegment?.type === 'text' @@ -837,18 +730,16 @@ function MessageContentInner({ return null } - // Executing tools already render an active row. An open subagent lane does - // not suppress the turn-level indicator: once its latest visible chunk has - // settled, the loader can bridge the wait until that lane (or a parallel - // sibling) emits again. - const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) - const showTrailingThinking = shouldShowTrailingThinking({ - isStreaming: phase === 'streaming', - isStreamIdle, - isRenderingStream: trailingStreamActivity, - hasExecutingTool, - lastSegmentType: lastSegment.type, - }) + const hasTrailingContent = lastSegment.type === 'text' || lastSegment.type === 'stopped' + + // Deterministic "between steps" signal: the turn is still streaming, nothing + // is actively running (a running tool/subagent renders its own spinner), and + // no trailing text is being revealed. Derived from explicit node state rather + // than guessing from the shape of the last segment. + const hasRunningWork = blocks.some( + (b) => b.toolCall?.status === 'executing' || (b.type === 'subagent' && b.endedAt === undefined) + ) + const showTrailingThinking = phase === 'streaming' && !hasTrailingContent && !hasRunningWork return (
@@ -864,16 +755,11 @@ function MessageContentInner({ segmentIndex: i, segmentCount: segments.length, })} - questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} - onQuestionDismiss={onQuestionDismiss} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ i === segments.length - 1 ? handleTrailingRevealChange : undefined } - onStreamActivityChange={ - i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined - } /> ) case 'agent_group': { @@ -910,7 +796,11 @@ function MessageContentInner({ ) } })} - {showTrailingThinking && } + {showTrailingThinking && ( +
+ +
+ )}
) } 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 4de6f1fe0c5..767575849b9 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 @@ -30,9 +30,6 @@ const TOOL_ICONS: Record = { glob: FolderCode, grep: Search, read: File, - mv: FolderCode, - cp: Layout, - mkdir: FolderCode, search_online: Search, scrape_page: Search, get_page_contents: Search, @@ -41,7 +38,6 @@ const TOOL_ICONS: Record = { manage_skill: Asterisk, user_memory: Database, function_execute: TerminalWindow, - run_code: TerminalWindow, superagent: Blimp, user_table: TableIcon, workspace_file: File, @@ -55,16 +51,12 @@ const TOOL_ICONS: Record = { auth: Integration, knowledge: Database, knowledge_base: Database, - search_knowledge_base: Database, table: TableIcon, - query_user_table: TableIcon, scheduled_task: Calendar, job: Calendar, agent: AgentIcon, custom_tool: Wrench, research: Search, - scout: Search, - search: Search, context_compaction: Asterisk, open_resource: Eye, file: File, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.test.ts deleted file mode 100644 index 49ae8bc7e06..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { shouldShowAssistantMessageActions } from './message-actions-visibility' - -describe('shouldShowAssistantMessageActions', () => { - it('restores actions when the trailing question card is dismissed', () => { - expect( - shouldShowAssistantMessageActions({ - phase: 'settled', - hasContent: true, - endsWithQuestion: true, - questionDismissed: true, - }) - ).toBe(true) - }) - - it('keeps actions hidden for an active or answered trailing question card', () => { - expect( - shouldShowAssistantMessageActions({ - phase: 'settled', - hasContent: true, - endsWithQuestion: true, - questionDismissed: false, - }) - ).toBe(false) - }) - - it('waits for the message to settle before restoring actions', () => { - expect( - shouldShowAssistantMessageActions({ - phase: 'streaming', - hasContent: true, - endsWithQuestion: true, - questionDismissed: true, - }) - ).toBe(false) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts deleted file mode 100644 index 0c84c1481b0..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessagePhase } from '@/app/workspace/[workspaceId]/home/components/message-content' - -interface AssistantMessageActionsVisibility { - phase: MessagePhase - hasContent: boolean - endsWithQuestion: boolean - questionDismissed: boolean -} - -/** - * Question cards replace the normal message actions while they are active or - * answered. Dismissing an active card restores those actions for the settled - * assistant message underneath it. - */ -export function shouldShowAssistantMessageActions({ - phase, - hasContent, - endsWithQuestion, - questionDismissed, -}: AssistantMessageActionsVisibility): boolean { - return phase === 'settled' && hasContent && (!endsWithQuestion || questionDismissed) -} 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 5d11700c8f5..cc6055e8a11 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 @@ -20,11 +20,7 @@ import { MessageContent, type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' -import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { - PendingTagIndicator, - parseLastQuestionTag, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { PendingTagIndicator } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -43,7 +39,6 @@ import type { import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' -import { shouldShowAssistantMessageActions } from './message-actions-visibility' interface MothershipChatProps { messages: ChatMessage[] @@ -176,8 +171,6 @@ interface AssistantMessageRowProps { message: ChatMessage isStreaming: boolean precedingUserContent?: string - /** Transcript-derived answers for this message's question card (renders the recap). */ - questionAnswers?: string[] rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -187,7 +180,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ message, isStreaming, precedingUserContent, - questionAnswers, rowClassName, onOptionSelect, onAnimatingChange, @@ -197,7 +189,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ const trimmedContent = message.content?.trim() ?? '' const [phase, setPhase] = useState(isStreaming ? 'streaming' : 'settled') - const [dismissedQuestionTag, setDismissedQuestionTag] = useState(null) const onAnimatingChangeRef = useRef(onAnimatingChange) onAnimatingChangeRef.current = onAnimatingChange @@ -214,23 +205,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - // A trailing question card replaces the copy/thumbs row while active or - // answered. Its raw tag is the dismissal identity so a later question added - // to the same turn cannot inherit an earlier card's dismissed state. - const endsWithQuestion = trimmedContent.endsWith('') - const questionTag = endsWithQuestion - ? trimmedContent.slice(trimmedContent.lastIndexOf('')) - : null - const questionDismissed = questionTag !== null && dismissedQuestionTag === questionTag - const handleQuestionDismiss = () => { - if (questionTag) setDismissedQuestionTag(questionTag) - } - const showActions = shouldShowAssistantMessageActions({ - phase, - hasContent: Boolean(message.content) || hasAnyBlocks, - endsWithQuestion, - questionDismissed, - }) + const showActions = phase === 'settled' && (message.content || hasAnyBlocks) return (
@@ -238,9 +213,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} - questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} - onQuestionDismiss={handleQuestionDismiss} onPhaseChange={setPhase} /> {showActions && ( @@ -364,34 +337,6 @@ export function MothershipChat({ return out }, [messages]) - /** - * Pairs each assistant question card with the user message that answered it - * (strict `Prompt — Answer` match). The paired user message is hidden — the - * answered card IS the user turn — and the assistant row renders the card - * as a recap with these answers, both live and after reload. - */ - const questionPairing = useMemo(() => { - const answersByIndex: Array = [] - const hiddenUserByIndex: Array = [] - for (const [index, message] of messages.entries()) { - if (message.role !== 'assistant') continue - // Check the answering user message BEFORE scanning content: a pairing - // needs one anyway, and this skips the O(content) `includes` scan over - // the still-growing streaming message (always the last row) on every - // snapshot flush. - const next = messages[index + 1] - if (!next || next.role !== 'user' || !next.content) continue - if (!message.content?.includes('')) continue - const questions = parseLastQuestionTag(message.content) - if (!questions) continue - const answers = parseQuestionAnswerMessage(questions, next.content) - if (!answers) continue - answersByIndex[index] = answers - hiddenUserByIndex[index + 1] = true - } - return { answersByIndex, hiddenUserByIndex } - }, [messages]) - /** * Always keep the last row in the rendered window. It is the live/streaming * row; unmounting it (by scrolling far enough up that it leaves the overscan @@ -523,22 +468,19 @@ export function MothershipChat({ style={{ transform: `translateY(${virtualItem.start}px)` }} > {msg.role === 'user' ? ( - questionPairing.hiddenUserByIndex[index] ? null : ( - - ) + ) : ( { - it('renders the completed verb for a successful tool result', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Compared workflows') - expect(markup).not.toContain('Comparing workflows') - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx index ad59b80d212..e4616e5fb99 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react' import { PillsRing } from '@sim/emcn' -import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { GenericResourceData } from '@/app/workspace/[workspaceId]/home/types' interface GenericResourceContentProps { @@ -41,7 +40,7 @@ export function GenericResourceContent({ data }: GenericResourceContentProps) { /> )} - {getToolStatusDisplayTitle(entry.displayTitle, entry.status)} + {entry.displayTitle} {entry.status === 'error' && ( Error diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1266ffa5412..5fbf767a4c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -248,8 +248,6 @@ const RESOURCE_INVALIDATORS: Record< }, filefolder: (qc, wId) => { qc.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(wId) }) - qc.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(wId) }) - qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, task: (qc, wId) => { qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts index 39abdc7b359..43cdbef772a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts @@ -33,7 +33,6 @@ const PORTABLE_KIND_TO_ID_FIELD = { workflow: 'workflowId', logs: 'executionId', skill: 'skillId', - mcp: 'serverId', integration: 'blockType', slash_command: 'command', } as const satisfies Partial> @@ -221,8 +220,6 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext { return { kind: 'logs', executionId: link.id, label: link.label } case 'skill': return { kind: 'skill', skillId: link.id, label: link.label } - case 'mcp': - return { kind: 'mcp', serverId: link.id, label: link.label } case 'integration': return { kind: 'integration', blockType: link.id, label: link.label } case 'slash_command': diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index f971a60e332..f5d948485cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -229,9 +229,7 @@ export function PromptEditor({ ({ useSkills: () => ({ data: [] }) })) -vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) })) vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) })) vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 3cc377bd39a..49aeededfd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -24,7 +24,6 @@ import { restoreSkillTriggerText, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' -import { type McpServer, useMcpServers } from '@/hooks/queries/mcp' import { type SkillDefinition, useSkills } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -156,11 +155,6 @@ export function usePromptEditor({ onPasteFiles, }: UsePromptEditorProps) { const { data: skills = [] } = useSkills(workspaceId) - const { data: allMcpServers = [] } = useMcpServers(workspaceId) - const mcpServers = useMemo( - () => allMcpServers.filter((server) => server.enabled && server.workspaceId === workspaceId), - [allMcpServers, workspaceId] - ) const [value, setValueState] = useState(initialValue) const valueRef = useRef(value) @@ -230,7 +224,6 @@ export function usePromptEditor({ const skillAutoMention = useSkillAutoMention({ skills, - mcpServers, setSelectedContexts: contextManagement.setSelectedContexts, }) @@ -279,8 +272,8 @@ export function usePromptEditor({ valueRef.current = converted setValueState(converted) } - seedRef.current = skills.length > 0 || mcpServers.length > 0 ? null : converted - }, [skills.length, mcpServers.length, applyAutoMentions]) + seedRef.current = skills.length > 0 ? null : converted + }, [skills.length, applyAutoMentions]) const existingResourceKeys = useMemo(() => { const keys = new Set() @@ -451,32 +444,6 @@ export function usePromptEditor({ [textareaRef, addContextNotified] ) - const handleMcpSelect = useCallback( - (server: McpServer) => { - const textarea = textareaRef.current - if (textarea) { - const currentValue = valueRef.current - const range = slashRangeRef.current - const insertAt = range?.start ?? textarea.selectionStart ?? currentValue.length - const end = range?.end ?? insertAt - const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1)) - const insertText = `${needsSpaceBefore ? ' ' : ''}${SKILL_CHIP_TRIGGER}${server.name} ` - const newValue = `${currentValue.slice(0, insertAt)}${insertText}${currentValue.slice(end)}` - const newPos = insertAt + insertText.length - - pendingCursorRef.current = newPos - valueRef.current = newValue - slashRangeRef.current = null - setSlashQuery(null) - dismissedSlashStartRef.current = null - setValueState(newValue) - } - - addContextNotified({ kind: 'mcp', serverId: server.id, label: server.name }) - }, - [textareaRef, addContextNotified] - ) - /** * Only reachable via Radix's own dismiss detection (outside click / * Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass @@ -1043,8 +1010,6 @@ export function usePromptEditor({ /** @internal Wiring consumed by the {@link PromptEditor} view. */ skills, /** @internal */ - mcpServers, - /** @internal */ availableResources, /** @internal */ mentionQuery, @@ -1061,8 +1026,6 @@ export function usePromptEditor({ /** @internal */ handleSkillSelect, /** @internal */ - handleMcpSelect, - /** @internal */ handlePlusMenuClose, /** @internal */ handleSkillsMenuClose, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx index 93c892bab5b..9d43b5bd773 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@sim/emcn' -import { AgentSkillsIcon, McpIcon } from '@/components/icons' -import type { McpServer } from '@/hooks/queries/mcp' +import { AgentSkillsIcon } from '@/components/icons' import type { SkillDefinition } from '@/hooks/queries/skills' /** @@ -25,12 +24,8 @@ export interface SkillsMenuHandle { interface SkillsMenuDropdownProps { /** Skills available in the current workspace. */ skills: SkillDefinition[] - /** Connected MCP servers available in the current workspace. */ - mcpServers: McpServer[] /** Called when a skill row is chosen (click / keyboard). */ onSkillSelect: (skill: SkillDefinition) => void - /** Called when an MCP server row is chosen. */ - onMcpSelect: (server: McpServer) => void /** Called when the menu closes so the host can reset slash state. */ onClose: () => void /** Host textarea — focus is restored to it on close. */ @@ -49,16 +44,7 @@ interface SkillsMenuDropdownProps { */ export const SkillsMenuDropdown = React.memo( React.forwardRef(function SkillsMenuDropdown( - { - skills, - mcpServers, - onSkillSelect, - onMcpSelect, - onClose, - textareaRef, - pendingCursorRef, - slashQuery, - }, + { skills, onSkillSelect, onClose, textareaRef, pendingCursorRef, slashQuery }, ref ) { const [open, setOpen] = useState(false) @@ -66,18 +52,14 @@ export const SkillsMenuDropdown = React.memo( const [activeIndex, setActiveIndex] = useState(0) const contentRef = useRef(null) - const filteredItems = useMemo(() => { + const filteredSkills = useMemo(() => { const q = (slashQuery ?? '').toLowerCase().trim() - const items = [ - ...skills.map((skill) => ({ kind: 'skill' as const, item: skill })), - ...mcpServers.map((server) => ({ kind: 'mcp' as const, item: server })), - ] - if (!q) return items - return items.filter(({ item }) => item.name.toLowerCase().includes(q)) - }, [skills, mcpServers, slashQuery]) - - const filteredItemsRef = useRef(filteredItems) - filteredItemsRef.current = filteredItems + if (!q) return skills + return skills.filter((skill) => skill.name.toLowerCase().includes(q)) + }, [skills, slashQuery]) + + const filteredSkillsRef = useRef(filteredSkills) + filteredSkillsRef.current = filteredSkills const activeIndexRef = useRef(activeIndex) activeIndexRef.current = activeIndex @@ -92,13 +74,12 @@ export const SkillsMenuDropdown = React.memo( }, []) const handleSelect = useCallback( - (target: (typeof filteredItems)[number]) => { - if (target.kind === 'skill') onSkillSelect(target.item) - else onMcpSelect(target.item) + (skill: SkillDefinition) => { + onSkillSelect(skill) setOpen(false) setActiveIndex(0) }, - [onSkillSelect, onMcpSelect] + [onSkillSelect] ) const handleSelectRef = useRef(handleSelect) @@ -110,7 +91,7 @@ export const SkillsMenuDropdown = React.memo( open: doOpen, close: doClose, moveActive: (delta: number) => { - const items = filteredItemsRef.current + const items = filteredSkillsRef.current if (items.length === 0) return setActiveIndex((i) => { const next = i + delta @@ -120,7 +101,7 @@ export const SkillsMenuDropdown = React.memo( }) }, selectActive: () => { - const items = filteredItemsRef.current + const items = filteredSkillsRef.current if (items.length === 0) return false const target = items[activeIndexRef.current] ?? items[0] if (!target) return false @@ -139,12 +120,12 @@ export const SkillsMenuDropdown = React.memo( // Sync DOM scroll to the keyboard-highlighted row. useEffect(() => { - if (filteredItems.length === 0) return + if (filteredSkills.length === 0) return const row = contentRef.current?.querySelector( `[data-filtered-idx="${activeIndex}"]` ) row?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex, filteredItems]) + }, [activeIndex, filteredSkills]) const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen) @@ -198,30 +179,30 @@ export const SkillsMenuDropdown = React.memo( onOpenAutoFocus={handleOpenAutoFocus} >
- {filteredItems.length > 0 ? ( - filteredItems.map((target, index) => { + {filteredSkills.length > 0 ? ( + filteredSkills.map((skill, index) => { const isActive = index === activeIndex return ( ) }) ) : (
- No skills or MCP servers + No skills
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts index 6aa5006ba54..8a962686df2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts @@ -3,7 +3,6 @@ import { escapeRegex, SKILL_CHIP_TRIGGER, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' -import type { McpServer } from '@/hooks/queries/mcp' import type { SkillDefinition } from '@/hooks/queries/skills' import type { ChatContext } from '@/stores/panel' @@ -16,12 +15,6 @@ import type { ChatContext } from '@/stores/panel' const WORD_BOUNDARY_REGEX = /^[\s.,;:!?(){}[\]"'`/\\<>\n]$/ type SkillContext = Extract -type McpContext = Extract -type SlashContext = SkillContext | McpContext - -function slashContextKey(context: SlashContext): string { - return context.kind === 'skill' ? `skill:${context.skillId}` : `mcp:${context.serverId}` -} /** * A skill trigger — the typed `/` or the stored EM SPACE sentinel — only counts @@ -39,8 +32,6 @@ function isTriggerPrefixAt(text: string, index: number): boolean { interface UseSkillAutoMentionProps { /** Skills available in the current workspace. */ skills: SkillDefinition[] - /** MCP servers available in the current workspace. */ - mcpServers: McpServer[] /** Setter for the host's selected contexts. */ setSelectedContexts: React.Dispatch> } @@ -67,18 +58,14 @@ interface ProcessChangeArgs { * speech-to-text. Swaps each matched typed `/` for the sentinel in the * returned string and registers any matched skill contexts. */ -export function useSkillAutoMention({ - skills, - mcpServers, - setSelectedContexts, -}: UseSkillAutoMentionProps) { +export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAutoMentionProps) { /** * Matcher built from skill names, longest-first so `/my-skill-extended` * wins over `/my-skill`. The trailing guard rejects partial matches that * continue into more name characters. */ const matcher = useMemo(() => { - const byName = new Map() + const byName = new Map() for (const skill of skills) { byName.set(skill.name.toLowerCase(), { kind: 'skill', @@ -86,15 +73,7 @@ export function useSkillAutoMention({ label: skill.name, }) } - for (const server of mcpServers) { - const key = server.name.toLowerCase() - if (!byName.has(key)) { - byName.set(key, { kind: 'mcp', serverId: server.id, label: server.name }) - } - } - const names = [...byName.values()] - .map((context) => context.label) - .sort((a, b) => b.length - a.length) + const names = [...skills].map((s) => s.name).sort((a, b) => b.length - a.length) if (names.length === 0) return { regex: null as RegExp | null, byName } // Match either trigger: the typed '/' or the stored sentinel, so both fresh // input and pasted/restored chips resolve. The trigger group is the match's @@ -102,24 +81,19 @@ export function useSkillAutoMention({ const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})` const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])` return { regex: new RegExp(pattern, 'gi'), byName } - }, [skills, mcpServers]) + }, [skills]) const matcherRef = useRef(matcher) matcherRef.current = matcher const mergeContexts = useCallback( - (additions: SlashContext[]) => { + (additions: SkillContext[]) => { if (additions.length === 0) return setSelectedContexts((prev) => { const existing = new Set( - prev - .filter( - (context): context is SlashContext => - context.kind === 'skill' || context.kind === 'mcp' - ) - .map(slashContextKey) + prev.filter((c): c is SkillContext => c.kind === 'skill').map((c) => c.skillId) ) - const fresh = additions.filter((context) => !existing.has(slashContextKey(context))) + const fresh = additions.filter((c) => !existing.has(c.skillId)) return fresh.length > 0 ? [...prev, ...fresh] : prev }) }, @@ -178,7 +152,7 @@ export function useSkillAutoMention({ if (!regex || !text) return text regex.lastIndex = 0 - const additions: SlashContext[] = [] + const additions: SkillContext[] = [] const seen = new Set() const slashIndices: number[] = [] let match: RegExpExecArray | null @@ -190,9 +164,8 @@ export function useSkillAutoMention({ // Rewrite every confirmed typed '/' (even a repeated skill) so duplicate // tokens chip consistently; tokens already on the sentinel need no edit. if (text[index] === '/') slashIndices.push(index) - const key = slashContextKey(context) - if (seen.has(key)) continue - seen.add(key) + if (seen.has(context.skillId)) continue + seen.add(context.skillId) additions.push(context) } mergeContexts(additions) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx index b5bb7ace728..77e90582205 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx @@ -52,7 +52,7 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men for (const rawCtx of contexts) { if (!rawCtx.label) continue const ctx = withResolvedBlockType(rawCtx) - const prefix = ctx.kind === 'skill' || ctx.kind === 'mcp' ? '/' : '@' + const prefix = ctx.kind === 'skill' ? '/' : '@' const token = `${prefix}${ctx.label}` const pattern = new RegExp(`(^|\\s)(${escapeRegex(token)})(\\s|$)`, 'g') let match: RegExpExecArray | null diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts deleted file mode 100644 index 5526c48aca8..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - invalidateResourceQueries: vi.fn(), - removeWorkflowFromActiveCache: vi.fn(), -})) - -vi.mock( - '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry', - () => ({ invalidateResourceQueries: mocks.invalidateResourceQueries }) -) -vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ - removeWorkflowFromActiveCache: mocks.removeWorkflowFromActiveCache, -})) - -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' -import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' -import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' - -function removeEvent(type: 'workflow' | 'file', id: string): PersistedStreamEventEnvelope { - return { - type: 'resource', - v: 1, - seq: 1, - ts: '', - stream: { streamId: 's', cursor: '1' }, - payload: { op: 'remove', resource: { type, id, title: id } }, - } as PersistedStreamEventEnvelope -} - -describe('handleResourceEvent removal', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('closes a deleted workflow tab and removes it from the established workflow cache', () => { - const deps = makeStreamLoopDeps() - const ctx = { deps } as StreamLoopContext - - handleResourceEvent(ctx, removeEvent('workflow', 'wf-1')) - - expect(deps.removeResource).toHaveBeenCalledWith('workflow', 'wf-1') - expect(mocks.removeWorkflowFromActiveCache).toHaveBeenCalledWith( - deps.queryClient, - 'ws-1', - 'wf-1' - ) - expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( - deps.queryClient, - 'ws-1', - 'workflow', - 'wf-1' - ) - }) - - it('closes other resource tabs through the same remove event path', () => { - const deps = makeStreamLoopDeps() - const ctx = { deps } as StreamLoopContext - - handleResourceEvent(ctx, removeEvent('file', 'file-1')) - - expect(deps.removeResource).toHaveBeenCalledWith('file', 'file-1') - expect(mocks.removeWorkflowFromActiveCache).not.toHaveBeenCalled() - expect(mocks.invalidateResourceQueries).toHaveBeenCalledWith( - deps.queryClient, - 'ws-1', - 'file', - 'file-1' - ) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 03af5c60165..b94384a8b34 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -11,7 +11,6 @@ import { } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' -import { removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' type ResourceEvent = Extract< @@ -47,12 +46,13 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven const resource = payload.resource if (payload.op === MothershipStreamV1ResourceOp.remove) { - const resourceType = resource.type as MothershipResourceType - removeResource(resourceType, resource.id) - if (resourceType === 'workflow') { - removeWorkflowFromActiveCache(queryClient, workspaceId, resource.id) - } - invalidateResourceQueries(queryClient, workspaceId, resourceType, resource.id) + removeResource(resource.type as MothershipResourceType, resource.id) + invalidateResourceQueries( + queryClient, + workspaceId, + resource.type as MothershipResourceType, + resource.id + ) onResourceEvent?.() return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index fad976acbbd..1a760f149f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -21,7 +21,6 @@ import { dispatchStreamEvent } from './dispatch-stream-event' import { createStreamLoopContext, type StreamLoopContext } from './stream-context' import { makeStreamLoopDeps, ref } from './stream-test-helpers' import type { ToolNode } from './turn-model' -import { contentBlocksToModel, modelToContentBlocks } from './turn-model-serialize' let seq = 0 function toolEnv(payload: Record): PersistedStreamEventEnvelope { @@ -125,127 +124,3 @@ describe('tool events (dispatch → model + side effects)', () => { expect(toolNode(ctx, 'wf-1').status).toBe('success') }) }) - -describe('integration gateway (full wire sequence → published snapshot)', () => { - const GATEWAY = 'call_integration_tool' - const CALL_ID = 'ig-1' - - const generating = () => - toolEnv({ - phase: 'call', - executor: 'go', - mode: 'sync', - toolCallId: CALL_ID, - toolName: GATEWAY, - status: 'generating', - }) - - const argsDelta = (argumentsDelta: string) => - toolEnv({ - phase: 'args_delta', - executor: 'go', - mode: 'sync', - toolCallId: CALL_ID, - toolName: GATEWAY, - argumentsDelta, - }) - - const gatewayFinalCall = () => - toolEnv({ - phase: 'call', - executor: 'go', - mode: 'sync', - toolCallId: CALL_ID, - toolName: GATEWAY, - arguments: { - toolId: 'gmail_read_v2', - description: 'Read recent emails', - arguments: { maxResults: 5 }, - }, - }) - - const resolvedOperationCall = () => - toolEnv({ - phase: 'call', - executor: 'sim', - mode: 'async', - toolCallId: CALL_ID, - toolName: 'gmail_read_v2', - arguments: { maxResults: 5, credentialId: 'cred-1' }, - }) - - /** The exact toolCall snapshot the browser publishes for this row. */ - function publishedToolCall(ctx: StreamLoopContext) { - const blocks = modelToContentBlocks(ctx.state.model) - const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID) - expect(block?.toolCall).toBeDefined() - return block!.toolCall! - } - - it('brands the row from streamed args while generating, then rebinds to the resolved operation', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - - // Provisional frame: neutral label, never the humanized gateway name. - dispatchStreamEvent(ctx, generating()) - expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration') - - // toolId alone brands only the icon (row component); text stays neutral. - dispatchStreamEvent(ctx, argsDelta('{"toolId":"gmail_read_v2",')) - expect(publishedToolCall(ctx).displayTitle).toBe('Calling integration') - expect(publishedToolCall(ctx).streamingArgs).toContain('"toolId":"gmail_read_v2"') - - // The model-authored activity phrase becomes the row text as it completes. - dispatchStreamEvent(ctx, argsDelta('"description":"Read recent emails",')) - expect(publishedToolCall(ctx).displayTitle).toBe('Read recent emails') - - dispatchStreamEvent(ctx, argsDelta('"arguments":{"maxResults":5}}')) - dispatchStreamEvent(ctx, gatewayFinalCall()) - expect(publishedToolCall(ctx)).toEqual( - expect.objectContaining({ - name: GATEWAY, - displayTitle: 'Read recent emails', - }) - ) - - // Second authoritative frame (same call id): rebind to the exact operation. - dispatchStreamEvent(ctx, resolvedOperationCall()) - const rebound = publishedToolCall(ctx) - expect(rebound).toEqual( - expect.objectContaining({ - name: 'gmail_read_v2', - displayTitle: 'Read recent emails', - integrationDescription: 'Read recent emails', - params: { maxResults: 5, credentialId: 'cred-1' }, - }) - ) - expect(rebound.streamingArgs).toBeUndefined() - - dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2')) - expect(publishedToolCall(ctx)).toEqual( - expect.objectContaining({ - name: 'gmail_read_v2', - status: 'success', - displayTitle: 'Read recent emails', - }) - ) - }) - - it('keeps the rebound branding across a snapshot rebuild (reconnect round-trip)', () => { - const ctx = createStreamLoopContext(makeStreamLoopDeps()) - dispatchStreamEvent(ctx, generating()) - dispatchStreamEvent(ctx, gatewayFinalCall()) - dispatchStreamEvent(ctx, resolvedOperationCall()) - dispatchStreamEvent(ctx, toolResult(CALL_ID, true, 'gmail_read_v2')) - - const rebuilt = contentBlocksToModel(modelToContentBlocks(ctx.state.model)) - const blocks = modelToContentBlocks(rebuilt) - const block = blocks.find((b) => b.type === 'tool_call' && b.toolCall?.id === CALL_ID) - expect(block?.toolCall).toEqual( - expect.objectContaining({ - name: 'gmail_read_v2', - status: 'success', - displayTitle: 'Read recent emails', - }) - ) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index 4ca4012f138..b49871bfbdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -22,9 +22,6 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' -/** Minimum spacing between text-driven snapshot flushes (see flushText). */ -const MIN_TEXT_FLUSH_INTERVAL_MS = 50 - export type ActiveTurn = { userMessageId: string assistantMessageId: string @@ -34,25 +31,6 @@ export type ActiveTurn = { export interface StreamLoopOptions { preserveExistingState?: boolean - /** - * The real wire cursor the preserved snapshot corresponds to. The - * preserve-state rebuild assigns synthetic seqs (1..M, M = synthesized - * envelope count) — a unit unrelated to wire seq — while the reducer's - * `seq <= lastSeq` idempotency guard compares against incoming REAL seqs. - * Re-baselining lastSeq to this cursor keeps the guard in wire units so a - * turn with many tool/subagent blocks but few wire events can never have - * M >= afterCursor+1 silently drop the first resumed events. - */ - resumeCursor?: string - /** - * Batch-replay mode: suppress every intermediate snapshot write and publish - * ONE atomic flush when the stream ends (processSSEStream calls forceFlush). - * A reconnect replay re-derives content the user already saw — rendering it - * incrementally collapses the visible message to a prefix and re-arms the - * smooth-reveal/fade over text that was already on screen. With one terminal - * flush the rendered content only ever appends. - */ - deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean @@ -69,8 +47,6 @@ export interface StreamLoopState { sawStreamError: boolean sawCompleteEvent: boolean scheduledTextFlushFrame: number | null - /** Trailing timer for the min-interval text-flush gate (see flushText). */ - scheduledTextFlushTimer: ReturnType | null } export interface StreamEventScope { @@ -159,8 +135,6 @@ export interface StreamLoopOps { isStale: () => boolean flush: () => void flushText: () => void - /** Real flush that bypasses `deferFlushes` — the batch-replay terminal flush. */ - forceFlush: () => void } export interface StreamLoopContext { @@ -191,27 +165,13 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext sawStreamError: false, sawCompleteEvent: false, scheduledTextFlushFrame: null, - scheduledTextFlushTimer: null, - } - - if (preserveState) { - // Convert the rebuilt model's synthetic lastSeq back into wire units (see - // StreamLoopOptions.resumeCursor). Without this, incoming real seqs are - // compared against a synthetic envelope count. - const resumeCursor = Number(deps.options.resumeCursor) - if (Number.isFinite(resumeCursor) && resumeCursor >= 0) { - state.model.lastSeq = resumeCursor - } } const isStale = () => (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) || deps.options.shouldContinue?.() === false - // Deferred (batch-replay) runs keep the previous refs intact until the - // terminal flush: they are what a mid-replay stop persists, and clearing - // them buys nothing when no intermediate flush will read them. - if (!preserveState && !isStale() && deps.options.deferFlushes !== true) { + if (!preserveState && !isStale()) { deps.streamingContentRef.current = '' deps.streamingBlocksRef.current = [] } @@ -227,8 +187,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext captureRevealedSimKeys( deps.revealedSimKeysRef.current, [deps.assistantId, state.streamRequestId], - modelContent, - modelBlocks + modelContent ) const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current if (!activeChatId) { @@ -282,51 +241,23 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext }) } - // Text flushes are the hot path (one per streamed chunk); every flush - // re-serializes the whole model and re-runs the transcript-wide memos - // downstream. The min-interval gate caps that at ~20 snapshots/sec — the - // visible pacing is owned by the smooth-text reveal, so a 50ms snapshot - // cadence is indistinguishable from per-frame. Tool/lifecycle flushes stay - // immediate, and they push the next text flush out via lastFlushAtMs. - let lastFlushAtMs = 0 - const flushAndStamp = () => { - lastFlushAtMs = Date.now() - flush() - } - const flushText = () => { - if (deps.options.deferFlushes === true) return if (isStale()) return - if (state.scheduledTextFlushFrame !== null || state.scheduledTextFlushTimer !== null) return + if (state.scheduledTextFlushFrame !== null) return if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - flushAndStamp() + flush() return } - const scheduleFrame = () => { - state.scheduledTextFlushFrame = window.requestAnimationFrame(() => { - state.scheduledTextFlushFrame = null - flushAndStamp() - }) - } - const waitMs = MIN_TEXT_FLUSH_INTERVAL_MS - (Date.now() - lastFlushAtMs) - if (waitMs <= 0) { - scheduleFrame() - return - } - state.scheduledTextFlushTimer = setTimeout(() => { - state.scheduledTextFlushTimer = null - scheduleFrame() - }, waitMs) + state.scheduledTextFlushFrame = window.requestAnimationFrame(() => { + state.scheduledTextFlushFrame = null + flush() + }) } const ops: StreamLoopOps = { isStale, - flush: () => { - if (deps.options.deferFlushes === true) return - flushAndStamp() - }, + flush, flushText, - forceFlush: flush, } return { state, ops, deps } 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 6064956677e..72e1bd2dabc 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 @@ -1,26 +1,30 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { - CallIntegrationTool, CrawlWebsite, - CreateFile, - CreateWorkflow, DeleteWorkflow, DeployApi, DeployChat, DeployMcp, - EditWorkflow, FunctionExecute, Glob, Grep, ManageCredential, + ManageCredentialOperation, ManageCustomTool, + ManageCustomToolOperation, ManageFolder, + ManageFolderOperation, ManageMcpTool, + ManageMcpToolOperation, ManageScheduledTask, + ManageScheduledTaskOperation, ManageSkill, + ManageSkillOperation, + MoveWorkflow, QueryLogs, Redeploy, + RenameWorkflow, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -30,8 +34,7 @@ import { WorkspaceFileOperation, } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' +import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' @@ -49,15 +52,12 @@ export const DEPLOY_TOOL_NAMES: Set = new Set([ Redeploy.id, ]) -export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id, 'mkdir', 'mv']) +export const FOLDER_TOOL_NAMES: Set = new Set([ManageFolder.id]) export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ - 'mv', - 'cp', + MoveWorkflow.id, + RenameWorkflow.id, DeleteWorkflow.id, - // Removed legacy tools, kept while their grace-period executors remain. - 'move_workflow', - 'rename_workflow', ]) export type StreamPayload = Record @@ -163,25 +163,6 @@ function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined return getWorkflowById(workspaceId, id)?.name } -function resolveTargetWorkflowName(args: Record | undefined): string | undefined { - const explicitName = stringParam(args?.workflowName) ?? stringParam(args?.name) - if (explicitName) return explicitName - - const registry = useWorkflowRegistry.getState() - return resolveWorkflowNameForDisplay(args?.workflowId ?? registry.hydration.workflowId) -} - -function resolveDeletedWorkflowTarget(workflowIds: unknown): string | undefined { - if (!Array.isArray(workflowIds) || workflowIds.length === 0) return undefined - const names = workflowIds - .map(resolveWorkflowNameForDisplay) - .filter((name): name is string => Boolean(name)) - if (names.length === 0) return undefined - if (workflowIds.length === 1) return names[0] - if (workflowIds.length === 2 && names.length === 2) return `${names[0]} and ${names[1]}` - return `${names[0]} and ${workflowIds.length - 1} more` -} - function resolveBlockNameForDisplay(blockId: unknown): string | undefined { const id = stringParam(blockId) if (!id) return undefined @@ -214,35 +195,17 @@ function resolveWorkspaceFileDisplayTitle( return undefined } -function functionExecuteTitle(title: string | undefined): string { - return title ?? 'Running code' +function resolveOperationDisplayTitle( + operation: unknown, + labels: Partial>, + fallback: string +): string { + const label = typeof operation === 'string' ? labels[operation] : undefined + return label ?? fallback } -/** - * Row text for an integration-gateway tool call: the model-authored activity - * `description`, readable the moment it completes in the still-streaming - * argument buffer — the same pattern `read`/`workspace_file` use for their - * streaming VFS targets. The trusted integration branding is the ICON, which - * the row component derives deterministically from the streamed `toolId` (or - * the rebound operation name) via Sim's block registry — the text carries no - * integration name. After Go's authoritative frame rebinds the row to the - * exact operation (e.g. `gmail_read_v2`), the preserved - * `integrationDescription` keeps the same text. Returns undefined until a - * description is readable (callers fall back to the neutral gateway label). - */ -export function resolveIntegrationToolDisplayTitle(tool: { - name: string - args?: Record - streamingArgs?: string - integrationDescription?: string -}): string | undefined { - if (tool.name === CallIntegrationTool.id) { - const description = - stringParam(tool.args?.description) ?? - extractStreamingStringArgument(tool.streamingArgs, 'description')?.trim() - return description || undefined - } - return tool.integrationDescription +function functionExecuteTitle(title: string | undefined): string { + return title ?? 'Running code' } export function resolveToolDisplayTitle(name: string, args?: Record): string { @@ -272,16 +235,6 @@ export function resolveToolDisplayTitle(name: string, args?: Record { - it('includes resource names as soon as they appear in streamed arguments', () => { - expect(resolveStreamingToolDisplayTitle('create_workflow', '{"name":"Lead Router"}')).toBe( - 'Creating Lead Router' - ) - expect( - resolveStreamingToolDisplayTitle( - 'manage_custom_tool', - '{"operation":"add","schema":{"function":{"name":"lookupWeather"}}}' - ) - ).toBe('Creating lookupWeather') - expect( - resolveStreamingToolDisplayTitle( - 'manage_folder', - '{"operation":"rename","path":"workflows/Old%20Name","name":"New Name"}' - ) - ).toBe('Renaming Old Name to New Name') - }) -}) - // A main-agent file delegation: trigger tool (main lane), subagent span, inner // workspace_file, span end, delegation result. function fileDelegationEvents(): PersistedStreamEventEnvelope[] { @@ -344,48 +323,6 @@ describe('modelToContentBlocks', () => { expect(blocksByType(blocks, 'subagent_end')).toHaveLength(0) expect(blocksByType(blocks, 'subagent')).toHaveLength(1) }) - - it('persists a completed compaction inside its subagent span', () => { - const sub: Scope = { - lane: 'subagent', - spanId: 'S1', - parentSpanId: 'main', - parentToolCallId: 'tc-workflow', - agentId: 'workflow', - } - const blocks = modelToContentBlocks( - build([ - env( - 1, - 'span', - { - kind: 'subagent', - event: 'start', - agent: 'workflow', - data: { tool_call_id: 'tc-workflow' }, - }, - sub - ), - env(2, 'run', { kind: 'compaction_start' }, sub), - env(3, 'run', { kind: 'compaction_done' }, sub), - ]) - ) - - const compaction = blocks.find( - (block) => block.type === 'tool_call' && block.toolCall?.name === 'context_compaction' - ) - expect(compaction).toEqual( - expect.objectContaining({ - spanId: 'S1', - parentSpanId: 'main', - toolCall: expect.objectContaining({ - calledBy: 'workflow', - displayTitle: 'Summarizing context', - status: 'success', - }), - }) - ) - }) }) describe('contentBlocksToModel round-trip', () => { 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 37df099dfc0..3e3b6cc9f6e 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 @@ -1,6 +1,5 @@ import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { - resolveIntegrationToolDisplayTitle, resolveStreamingToolDisplayTitle, resolveToolDisplayTitle, } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' @@ -39,14 +38,10 @@ function toolStatusToNode(status: ToolCallStatus): NodeStatus { /** * Resolves a tool row's display title with the same precedence the live handler - * used: the integration gateway's model-authored activity description first - * (live even mid-argument-stream; the integration brand is the row icon), then - * the streaming-args title while args stream, then the arg-derived title, then - * the explicit `ui.title`. + * used: the streaming-args title wins while args stream, then the arg-derived + * title, then the explicit `ui.title`. */ function toolDisplayTitle(node: ToolNode): string | undefined { - const integrationTitle = resolveIntegrationToolDisplayTitle(node) - if (integrationTitle) return integrationTitle const streamingTitle = node.streamingArgs ? resolveStreamingToolDisplayTitle(node.name, node.streamingArgs) : undefined @@ -131,9 +126,6 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] { name: node.name, status: nodeToToolStatus(node.status), ...(displayTitle ? { displayTitle } : {}), - ...(node.integrationDescription - ? { integrationDescription: node.integrationDescription } - : {}), ...(node.args ? { params: node.args } : {}), ...(node.streamingArgs ? { streamingArgs: node.streamingArgs } : {}), ...(node.result @@ -225,31 +217,16 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { // double-cast-allowed: synthetic replay envelope rebuilt from ContentBlocks for reduceEvent only; payloads are intentionally the minimal shape the reducer reads (no executor/mode), never provider-parsed or re-emitted on the wire }) as unknown as PersistedStreamEventEnvelope - const scopeFor = (block: ContentBlock): Record | undefined => { - // Legacy/degraded snapshots (older persisted rows, pre-span stop payloads) - // can carry lane linkage without spanId. Fall back to the deterministic - // `span:${parentToolCallId}` id — the same convention reduceEvent uses for - // scope-less span starts — so the lane survives the rebuild instead of - // collapsing into the main lane (subagent text at root, flat layout). - const laneLinked = - block.type === 'subagent' || - block.type === 'subagent_text' || - block.type === 'subagent_thinking' || - Boolean(block.subagent) || - Boolean(block.toolCall?.calledBy) - const spanId = - block.spanId ?? - (laneLinked && block.parentToolCallId ? `span:${block.parentToolCallId}` : undefined) - return spanId + const scopeFor = (block: ContentBlock): Record | undefined => + block.spanId ? { lane: 'subagent', - spanId, + spanId: block.spanId, ...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}), ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), ...(block.subagent ? { agentId: block.subagent } : {}), } : undefined - } for (const block of blocks) { if (block.type === 'subagent') { @@ -300,11 +277,6 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel { arguments: tc.params, // Preserve a server-provided title that isn't derivable from args. ...(tc.displayTitle ? { ui: { title: tc.displayTitle } } : {}), - // Rebound gateway rows keep their model-authored activity phrase - // across a snapshot rebuild (the resolved args no longer carry it). - ...(tc.integrationDescription - ? { integrationDescription: tc.integrationDescription } - : {}), }, 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 4681ae8e402..85d40741490 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 @@ -433,49 +433,7 @@ describe('reduceEvent — error tag + compaction coverage', () => { (n) => n.kind === 'tool' && n.name === 'context_compaction' ) as ToolNode expect(compaction.status).toBe('success') - expect(compaction.uiTitle).toBe('Summarizing context') - }) - - it('pairs concurrent compactions only within their scoped subagent spans', () => { - const scopeA: Scope = { - lane: 'subagent', - spanId: 'S1', - parentSpanId: MAIN_SPAN, - parentToolCallId: 'tc-A', - agentId: 'workflow', - } - const scopeB: Scope = { - lane: 'subagent', - spanId: 'S2', - parentSpanId: MAIN_SPAN, - parentToolCallId: 'tc-B', - agentId: 'workflow', - } - const m = apply([ - envelope(1, 'run', { kind: 'compaction_start' }, scopeA), - envelope(2, 'run', { kind: 'compaction_start' }, scopeB), - envelope(3, 'run', { kind: 'compaction_done' }, scopeA), - ]) - - expect(agent(m, 'S1').agentId).toBe('workflow') - expect(agent(m, 'S2').agentId).toBe('workflow') - expect(tool(m, 'compaction:1')).toEqual( - expect.objectContaining({ - spanId: 'S1', - status: 'success', - uiTitle: 'Summarizing context', - }) - ) - expect(tool(m, 'compaction:2')).toEqual( - expect.objectContaining({ - spanId: 'S2', - status: 'running', - uiTitle: 'Summarizing context', - }) - ) - - reduceEvent(m, envelope(4, 'run', { kind: 'compaction_done' }, scopeB)) - expect(tool(m, 'compaction:2').status).toBe('success') + expect(compaction.uiTitle).toBe('Compacted context') }) }) @@ -526,33 +484,3 @@ describe('turn-terminal propagation', () => { expect(tool(m, 'tc-1').status).toBe('error') }) }) - -describe('reduceEvent — span-start owner reconciliation', () => { - it('corrects a nonempty mismatched provisional lane owner from the authoritative start', () => { - const model = createTurnModel() - // A content event races ahead of the span start; its scope names the - // FORWARDING caller (superagent), not the lane's real owner. - reduceEvent( - model, - envelope( - 1, - 'text', - { channel: 'assistant', text: 'early chunk' }, - { lane: 'subagent', spanId: 'S1', agentId: 'superagent', parentToolCallId: 'd1' } - ) - ) - reduceEvent( - model, - envelope( - 2, - 'span', - { kind: 'subagent', event: 'start', agent: 'workflow', data: { tool_call_id: 'd1' } }, - { lane: 'subagent', spanId: 'S1', parentToolCallId: 'd1' } - ) - ) - const laneId = model.agentBySpanId.get('S1') - const lane = laneId ? model.nodes.get(laneId) : undefined - if (!lane || lane.kind !== 'agent') throw new Error('expected agent lane for S1') - expect((lane as AgentNode).agentId).toBe('workflow') - }) -}) 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 2f634a46fe0..fde7fd7e1e4 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 @@ -8,10 +8,7 @@ import { MothershipStreamV1SpanPayloadKind, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' -import { CallIntegrationTool } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { CONTEXT_COMPACTION_DISPLAY_TITLE } from '@/lib/copilot/tools/tool-display' /** * The single deterministic model of one assistant turn, derived purely from the @@ -58,13 +55,6 @@ export interface ToolNode extends NodeBase { args?: Record streamingArgs?: string uiTitle?: string - /** - * Model-authored activity phrase for a gateway-resolved integration call - * (e.g. "Reading recent emails"). Captured when the authoritative resolved - * frame rebinds the node's name to the exact operation, because the resolved - * args no longer carry the gateway's `description` field. - */ - integrationDescription?: string /** Per-call `ui.hidden` flag — the node is tracked for side effects but not rendered. */ hidden?: boolean result?: { success: boolean; output?: unknown; error?: string } @@ -190,26 +180,6 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } -/** - * The integration gateway intentionally emits a second authoritative call frame - * under the SAME provider call id once Go resolves the exact server-owned - * operation (call_integration_tool -> e.g. gmail_read_v2). Rebind the node to - * that operation so the row brands from the real integration (name -> block - * registry) and keep only the model-authored `description` for presentation — - * the caller then replaces args with the resolved operation args, which no - * longer carry the gateway envelope fields. - */ -function rebindResolvedIntegrationCall(node: ToolNode, toolName: string): void { - if (node.name !== CallIntegrationTool.id) return - if (!toolName || toolName === CallIntegrationTool.id) return - const description = - asString(node.args?.description)?.trim() || - extractStreamingStringArgument(node.streamingArgs, 'description')?.trim() - if (description) node.integrationDescription = description - node.name = toolName - node.streamingArgs = undefined -} - /** * Reads a wire event payload as a generic record. The payload is a wide * discriminated union; the reducer accesses fields uniformly, so this narrows @@ -503,13 +473,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq, tsMs ) - rebindResolvedIntegrationCall(node, toolName) if (isRecord(payload.arguments)) node.args = payload.arguments - // Only the snapshot-replay path (contentBlocksToModel) carries this - // field — the live wire never does; it restores the rebound gateway - // description across a preserve-state rebuild. - const restoredDescription = asString(payload.integrationDescription) - if (restoredDescription) node.integrationDescription = restoredDescription // Tool-call titles are derived from the tool name (+args) at serialize // time; the stream only carries behavioral flags now. const ui = isRecord(payload.ui) ? payload.ui : undefined @@ -551,28 +515,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve if (payload.event === MothershipStreamV1SpanLifecycleEvent.start) { breakLane(model, parentSpanId, tsMs) const existingId = model.agentBySpanId.get(resolvedSpanId) - const existing = existingId ? model.nodes.get(existingId) : undefined - if (existing && existing.kind === 'agent') { - // The lane was pre-created by ensureSubagentLane from a content event - // that raced ahead of this start. That event's scope may omit agentId - // (contract-optional) while only this start carries payload.agent — - // an empty agentId makes the downstream parsers drop the whole lane. - // Reconcile instead of ignoring the start — and overwrite a NONEMPTY - // mismatched provisional owner too: a racing content event's - // 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 (!existing.triggerToolCallId && triggerToolCallId) { - existing.triggerToolCallId = triggerToolCallId - } - if (existing.parentSpanId === MAIN_SPAN && parentSpanId !== MAIN_SPAN) { - existing.parentSpanId = parentSpanId - } - if (existing.startedAtMs === undefined && tsMs !== undefined) { - existing.startedAtMs = tsMs - } - break - } + if (existingId && model.nodes.has(existingId)) break const node: AgentNode = { kind: 'agent', id: resolvedSpanId, @@ -603,7 +546,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve const payload = payloadRecord(envelope.payload) const kind = payload.kind if (kind === MothershipStreamV1RunKind.compaction_start) { - ensureSubagentLane(model, spanId, scope, seq, tsMs) const node = upsertToolNode( model, `compaction:${seq}`, @@ -612,20 +554,18 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve seq, tsMs ) - node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE + node.uiTitle = 'Compacting context...' } else if (kind === MothershipStreamV1RunKind.compaction_done) { - ensureSubagentLane(model, spanId, scope, seq, tsMs) let finalized = false for (let i = model.order.length - 1; i >= 0; i--) { const node = model.nodes.get(model.order[i]) if ( node?.kind === 'tool' && - node.spanId === spanId && node.name === 'context_compaction' && node.status === 'running' ) { node.status = 'success' - node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE + node.uiTitle = 'Compacted context' finalized = true break } @@ -640,7 +580,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve tsMs ) node.status = 'success' - node.uiTitle = CONTEXT_COMPACTION_DISPLAY_TITLE + node.uiTitle = 'Compacted context' } } break 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 93757767892..df2631a16f7 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 @@ -142,61 +142,79 @@ describe('reconcileLiveAssistantTurn', () => { }) describe('selectReconnectReplayState', () => { - it('continues from a nonzero cursor when live streaming state exists in memory', () => { - const currentBlock: ContentBlock = { type: 'text', content: 'Hello world' } + it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => { + const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' } const result = selectReconnectReplayState({ afterCursor: '4', - currentContent: 'Hello world', - currentBlocks: [currentBlock], + cachedLiveAssistant: { + content: 'Hello world', + contentBlocks: [cachedBlock], + }, + currentContent: 'Hello', + currentBlocks: [], }) expect(result).toEqual({ afterCursor: '4', + content: 'Hello world', + contentBlocks: [cachedBlock], preserveExistingState: true, - source: 'live', + source: 'cache', }) }) - it('continues when only blocks carry live state (e.g. tool-only turn)', () => { + it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => { const result = selectReconnectReplayState({ afterCursor: '4', + cachedLiveAssistant: null, currentContent: '', - currentBlocks: [{ type: 'tool_call', toolCall: { id: 't1', name: 'grep' } } as ContentBlock], + currentBlocks: [], }) expect(result).toEqual({ - afterCursor: '4', - preserveExistingState: true, - source: 'live', + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', }) }) - it('replays the buffer from seq 0 when a nonzero cursor has no live in-memory state', () => { + it('resets when cached live content diverges from the local prefix', () => { const result = selectReconnectReplayState({ afterCursor: '4', - currentContent: '', - currentBlocks: [], + cachedLiveAssistant: { + content: 'Goodbye world', + contentBlocks: [{ type: 'text', content: 'Goodbye world' }], + }, + currentContent: 'Hello', + currentBlocks: [{ type: 'text', content: 'Hello' }], }) expect(result).toEqual({ afterCursor: '0', + content: '', + contentBlocks: [], preserveExistingState: false, source: 'reset', }) }) - it('resets for cursor zero replay even when local state exists', () => { + it('resets current state for cursor zero replay', () => { const currentBlock: ContentBlock = { type: 'text', content: 'Hello' } const result = selectReconnectReplayState({ afterCursor: '0', + cachedLiveAssistant: null, currentContent: 'Hello', currentBlocks: [currentBlock], }) expect(result).toEqual({ afterCursor: '0', + content: '', + contentBlocks: [], preserveExistingState: false, source: 'reset', }) 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 10e961b6386..e81ec0603f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -314,8 +314,6 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.blockType === 'string' case 'skill': return typeof value.skillId === 'string' - case 'mcp': - return typeof value.serverId === 'string' default: return false } @@ -606,7 +604,6 @@ function toRawPersistedContentBlockBody(block: ContentBlock): Record | null currentContent: string currentBlocks: ContentBlock[] }): ReconnectReplaySelection { - const { afterCursor, currentContent, currentBlocks } = params - const hasLiveState = currentContent.length > 0 || currentBlocks.length > 0 - if (!isZeroStreamCursor(afterCursor) && hasLiveState) { - return { afterCursor, preserveExistingState: true, source: 'live' } + const { afterCursor, cachedLiveAssistant, currentContent, currentBlocks } = params + if (isZeroStreamCursor(afterCursor)) { + return { + afterCursor, + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', + } + } + + const cachedContent = cachedLiveAssistant?.content ?? '' + const cachedBlocks = cachedLiveAssistant?.contentBlocks ?? [] + const cachedHasLiveState = cachedContent.length > 0 || cachedBlocks.length > 0 + const cachedIsAhead = + cachedHasLiveState && + cachedContent.length >= currentContent.length && + cachedContent.startsWith(currentContent) && + cachedBlocks.length >= currentBlocks.length + + if (cachedIsAhead) { + return { + afterCursor, + content: cachedContent, + contentBlocks: [...cachedBlocks], + preserveExistingState: true, + source: 'cache', + } + } + + return { + afterCursor: '0', + content: '', + contentBlocks: [], + preserveExistingState: false, + source: 'reset', } - return { afterCursor: '0', preserveExistingState: false, source: 'reset' } } export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set { @@ -1200,14 +1203,23 @@ export function useChat( expectedGen?: number, options?: { preserveExistingState?: boolean - resumeCursor?: string - deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean } ) => Promise<{ sawStreamError: boolean; sawComplete: boolean }> >(async () => ({ sawStreamError: false, sawComplete: false })) + const attachToExistingStreamRef = useRef< + (opts: { + streamId: string + assistantId: string + expectedGen: number + initialBatch?: StreamBatchResponse | null + afterCursor?: string + targetChatId?: string + shouldContinue?: () => boolean + }) => Promise<{ error: boolean; aborted: boolean }> + >(async () => ({ error: false, aborted: true })) const retryReconnectRef = useRef< (opts: { streamId: string @@ -1293,29 +1305,44 @@ export function useChat( }, []) const applyReconnectReplaySelection = useCallback( - (streamId: string, afterCursor: string): ReconnectReplaySelection => { + ( + streamId: string, + assistantId: string, + afterCursor: string, + options?: { targetChatId?: string; chatHistory?: MothershipChatHistory } + ): ReconnectReplaySelection => { + const cachedHistory = + options?.chatHistory ?? + (options?.targetChatId + ? queryClient.getQueryData( + mothershipChatKeys.detail(options.targetChatId) + ) + : undefined) + const cachedLiveAssistant = cachedHistory?.messages.find( + (message) => message.id === assistantId + ) const selection = selectReconnectReplayState({ afterCursor, + cachedLiveAssistant: cachedLiveAssistant ? toDisplayMessage(cachedLiveAssistant) : null, currentContent: streamingContentRef.current, currentBlocks: streamingBlocksRef.current, }) - // A reset replays from cursor 0 into a fresh model that never reads - // these refs — keep the previous snapshot visible (and stop-persistable) - // until the replay's terminal flush overwrites it, instead of collapsing - // the rendered message to empty. + streamingContentRef.current = selection.content + streamingBlocksRef.current = selection.contentBlocks lastCursorRef.current = selection.afterCursor if (selection.afterCursor === '0' && afterCursor !== '0') { logger.info('Resetting stream replay cursor after reconnect state mismatch', { streamId, + targetChatId: options?.targetChatId ?? cachedHistory?.id, previousCursor: afterCursor, }) } return selection }, - [] + [queryClient] ) const clearActiveTurn = useCallback(() => { @@ -1717,11 +1744,18 @@ export function useChat( const activeStreamId = chatHistory.activeStreamId appliedChatHistoryKeyRef.current = hydrationKey const mappedMessages = chatHistory.messages.map(toDisplayMessage) + const snapshotEvents = Array.isArray(chatHistory.streamSnapshot?.events) + ? chatHistory.streamSnapshot.events + : [] + const snapshotHasCompleteEvent = snapshotEvents.some( + (entry) => entry?.event?.type === MothershipStreamV1EventType.complete + ) const shouldReconnectActiveStream = Boolean(activeStreamId) && !sendingRef.current && activeStreamId !== locallyTerminalStreamIdRef.current && - !isTerminalStreamStatus(chatHistory.streamSnapshot?.status) + !isTerminalStreamStatus(chatHistory.streamSnapshot?.status) && + !snapshotHasCompleteEvent if (!activeStreamId && locallyTerminalStreamIdRef.current) { locallyTerminalStreamIdRef.current = undefined @@ -1779,6 +1813,9 @@ export function useChat( if (shouldReconnectActiveStream && activeStreamId) { const gen = ++streamGenRef.current const abortController = new AbortController() + const previousStreamId = streamIdRef.current ?? activeTurnRef.current?.userMessageId + const reconnectAfterCursor = + previousStreamId === activeStreamId ? lastCursorRef.current || '0' : '0' cancelActiveStreamRecovery() const replacedController = abortControllerRef.current if (replacedController && !replacedController.signal.aborted) { @@ -1789,24 +1826,68 @@ export function useChat( streamIdRef.current = activeStreamId setTransportReconnecting() - // Load-time reconnects always rebuild the live turn from the Redis - // replay buffer (seq 0): the buffer is the source of truth for an - // in-flight turn, and any local state here is detached from the stream - // loop that produced it. The DB transcript only supplies prior turns. - // If the buffer is empty on a terminal run, the resume flow finalizes - // and refetches the persisted transcript from the DB instead. const assistantId = getLiveAssistantMessageId(activeStreamId) - streamingContentRef.current = '' - streamingBlocksRef.current = [] - lastCursorRef.current = '0' + let snapshotReplayAfterCursor: string + if (snapshotEvents.length > 0) { + streamingContentRef.current = '' + streamingBlocksRef.current = [] + lastCursorRef.current = '0' + snapshotReplayAfterCursor = '0' + } else { + const replaySelection = applyReconnectReplaySelection( + activeStreamId, + assistantId, + reconnectAfterCursor, + { targetChatId: chatHistory.id, chatHistory } + ) + snapshotReplayAfterCursor = replaySelection.afterCursor + } const reconnect = async () => { - const succeeded = await retryReconnectRef.current({ - streamId: activeStreamId, - assistantId, - gen, - targetChatId: chatHistory.id, - }) + const initialSnapshot = chatHistory.streamSnapshot + const snapshotEvents = Array.isArray(initialSnapshot?.events) + ? (initialSnapshot.events as StreamBatchEvent[]) + : [] + + let reconnectResult: Awaited> | null = + null + const replaySnapshotEvents = snapshotEvents.filter( + (entry) => + !isAlreadyProcessedStreamCursor(String(entry.eventId), snapshotReplayAfterCursor) + ) + if (replaySnapshotEvents.length > 0) { + try { + reconnectResult = await attachToExistingStreamRef.current({ + streamId: activeStreamId, + assistantId, + expectedGen: gen, + initialBatch: { + success: true, + events: replaySnapshotEvents, + previewSessions: snapshotPreviewSessions, + status: initialSnapshot?.status ?? 'unknown', + }, + afterCursor: snapshotReplayAfterCursor, + targetChatId: chatHistory.id, + }) + } catch (error) { + logger.warn('Snapshot stream reconnect failed; falling back to retry', { + chatId: chatHistory.id, + streamId: activeStreamId, + error: toError(error).message, + }) + } + } + + const succeeded = + reconnectResult !== null + ? !reconnectResult.error || reconnectResult.aborted + : await retryReconnectRef.current({ + streamId: activeStreamId, + assistantId, + gen, + targetChatId: chatHistory.id, + }) if (succeeded && streamGenRef.current === gen && sendingRef.current) { finalizeRef.current({ targetChatId: chatHistory.id }) return @@ -1834,8 +1915,10 @@ export function useChat( cancelActiveStreamReader, cancelActiveStreamRecovery, flushPendingResources, + queryClient, recoverPendingClientWorkflowTools, seedPreviewSessions, + applyReconnectReplaySelection, setTransportIdle, setTransportReconnecting, ]) @@ -1847,8 +1930,6 @@ export function useChat( expectedGen?: number, options?: { preserveExistingState?: boolean - resumeCursor?: string - deferFlushes?: boolean suppressedWorkflowToolStartIds?: ReadonlySet targetChatId?: string shouldContinue?: () => boolean @@ -1957,17 +2038,6 @@ export function useChat( state.scheduledTextFlushFrame = null ops.flush() } - if (state.scheduledTextFlushTimer !== null) { - clearTimeout(state.scheduledTextFlushTimer) - state.scheduledTextFlushTimer = null - ops.flush() - } - // Batch-replay mode publishes exactly one snapshot, here at the end, - // so the rendered message goes stale-prefix -> full in a single step - // instead of collapsing and re-revealing through partial flushes. - if (options?.deferFlushes) { - ops.forceFlush() - } if (streamReaderRef.current === reader) { streamReaderRef.current = null } @@ -2039,9 +2109,6 @@ export function useChat( : {}), } ) - if (response.status === 404) { - throw new StreamGoneError(streamId) - } if (!response.ok) { throw new Error(`Stream resume batch failed: ${response.status}`) } @@ -2140,14 +2207,14 @@ export function useChat( return { error: false, aborted: true } } - // `afterCursor` must be the cursor the current streaming refs correspond - // to (or '0' with a fresh rebuild) — the seed replay re-baselines the - // rebuilt model's seq high-water mark to it, so a cursor ahead of the - // refs silently drops the seed events as replays. const initialReplaySelection: Pick< ReconnectReplaySelection, 'afterCursor' | 'preserveExistingState' - > = applyReconnectReplaySelection(streamId, afterCursor) + > = opts.initialBatch + ? { afterCursor, preserveExistingState: true } + : applyReconnectReplaySelection(streamId, assistantId, afterCursor, { + ...(targetChatId ? { targetChatId } : {}), + }) let latestCursor = initialReplaySelection.afterCursor let preserveNextReplayState = initialReplaySelection.preserveExistingState let seedEvents = opts.initialBatch?.events ?? [] @@ -2166,8 +2233,6 @@ export function useChat( expectedGen, { preserveExistingState: preserveNextReplayState, - resumeCursor: latestCursor, - deferFlushes: true, suppressedWorkflowToolStartIds: suppressedSeedWorkflowToolStartIds, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), @@ -2211,9 +2276,6 @@ export function useChat( : {}), } ) - if (sseRes.status === 404) { - throw new StreamGoneError(streamId) - } if (!sseRes.ok || !sseRes.body) { throw new Error(RECONNECT_TAIL_ERROR) } @@ -2230,7 +2292,6 @@ export function useChat( expectedGen, { preserveExistingState: preserveNextReplayState, - resumeCursor: latestCursor, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), } @@ -2267,9 +2328,9 @@ export function useChat( streamStatus = batch.status suppressedSeedWorkflowToolStartIds = getReplayCompletedWorkflowToolCallIds(seedEvents) - // `latestCursor` stays at the pre-batch position so the seed replay - // at the top of the loop folds the batch events into the model; the - // replay advances the cursor after applying them. + if (batch.events.length > 0) { + latestCursor = String(batch.events[batch.events.length - 1].eventId) + } if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) { if (activeAbort.signal.aborted || streamGenRef.current !== expectedGen) { @@ -2303,6 +2364,7 @@ export function useChat( setTransportStreaming, ] ) + attachToExistingStreamRef.current = attachToExistingStream const resumeOrFinalize = useCallback( async (opts: { @@ -2318,7 +2380,9 @@ export function useChat( if (streamGenRef.current !== gen || signal?.aborted || shouldContinue?.() === false) return - const replaySelection = applyReconnectReplaySelection(streamId, afterCursor) + const replaySelection = applyReconnectReplaySelection(streamId, assistantId, afterCursor, { + ...(targetChatId ? { targetChatId } : {}), + }) const batch = await fetchStreamBatch(streamId, replaySelection.afterCursor, signal) if (streamGenRef.current !== gen || shouldContinue?.() === false) return seedStreamBatchPreviewSessions(batch) @@ -2331,8 +2395,6 @@ export function useChat( gen, { preserveExistingState: replaySelection.preserveExistingState, - resumeCursor: replaySelection.afterCursor, - deferFlushes: true, suppressedWorkflowToolStartIds: getReplayCompletedWorkflowToolCallIds(batch.events), ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), @@ -2347,12 +2409,6 @@ export function useChat( return } - // Pass the cursor the streaming refs correspond to — NOT the batch's - // last event id. The seed replay re-baselines the rebuilt model to this - // cursor before folding the batch in; a cursor already advanced past - // the batch made the replay drop every event as a duplicate, which - // rendered an empty message (and suffix-only text once the tail - // appended to it). const reconnectResult = await attachToExistingStream({ streamId, assistantId, @@ -2360,7 +2416,10 @@ export function useChat( initialBatch: batch, ...(targetChatId ? { targetChatId } : {}), ...(shouldContinue ? { shouldContinue } : {}), - afterCursor: replaySelection.afterCursor, + afterCursor: + batch.events.length > 0 + ? String(batch.events[batch.events.length - 1].eventId) + : replaySelection.afterCursor, }) if ( @@ -2479,18 +2538,6 @@ export function useChat( } return true } - if (isStreamGoneError(err)) { - // Nothing left to resume (no run for the stream) — the persisted - // DB transcript is authoritative now. Finalize so the detail - // query refetches it instead of surfacing a reconnect error. - logger.warn('Stream no longer exists; falling back to persisted transcript', { - streamId, - }) - if (streamGenRef.current === gen) { - finalizeRef.current({ ...(targetChatId ? { targetChatId } : {}) }) - } - return true - } if (isStreamSchemaValidationError(err)) { logger.error('Reconnect halted by client-side stream schema enforcement', { streamId, @@ -2718,15 +2765,6 @@ export function useChat( ...(typeof block.timestamp === 'number' ? { timestamp: block.timestamp } : {}), ...(typeof block.endedAt === 'number' ? { endedAt: block.endedAt } : {}), } - // Span identity must survive this serializer too (matching - // toRawPersistedContentBlock): a stop-persisted turn that loses - // spanId/parentSpanId permanently renders through the legacy flat - // parser — nested subagents hoist to the top level on every reload. - const spanIdentity = { - ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), - ...(block.spanId ? { spanId: block.spanId } : {}), - ...(block.parentSpanId ? { parentSpanId: block.parentSpanId } : {}), - } if (block.type === 'tool_call' && block.toolCall) { const isCancelled = block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled' @@ -2744,7 +2782,7 @@ export function useChat( ...(display ? { display } : {}), calledBy: block.toolCall.calledBy, }, - ...spanIdentity, + ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), ...timing, } } @@ -2752,7 +2790,7 @@ export function useChat( type: block.type, content: block.content, ...(block.subagent ? { lane: 'subagent' } : {}), - ...spanIdentity, + ...(block.parentToolCallId ? { parentToolCallId: block.parentToolCallId } : {}), ...timing, } }) @@ -3003,7 +3041,6 @@ export function useChat( ...('folderId' in c && c.folderId ? { folderId: c.folderId } : {}), ...(c.kind === 'skill' && 'skillId' in c ? { skillId: c.skillId } : {}), ...(c.kind === 'integration' && 'blockType' in c ? { blockType: c.blockType } : {}), - ...(c.kind === 'mcp' && 'serverId' in c ? { serverId: c.serverId } : {}), })) const cachedUserMsg: PersistedMessage = { id: userMessageId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index fa85cc7cadf..20bc2513dab 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -73,8 +73,6 @@ export interface ToolCallInfo { name: string status: ToolCallStatus displayTitle?: string - /** Model-authored activity phrase for a gateway-resolved integration call. */ - integrationDescription?: string params?: Record calledBy?: string result?: ToolCallResult @@ -138,7 +136,6 @@ export interface ChatMessageContext { chatId?: string blockType?: string skillId?: string - serverId?: string } export interface ChatMessage { @@ -160,8 +157,6 @@ export const SUBAGENT_LABELS: Record = { knowledge: 'Knowledge Agent', table: 'Table Agent', custom_tool: 'Custom Tool Agent', - scout: 'Scout Agent', - search: 'Search Agent', superagent: 'Superagent', run: 'Run Agent', agent: 'Tools Agent', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index 629524f244e..e88c30a17cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -61,7 +61,8 @@ export function getBlockIconAndColor( if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor } } const normalized = normalizeToolId(toolName) - if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + if (normalized === 'load_skill' || normalized === 'load_user_skill') + return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } const toolBlock = getBlockByToolName(normalized) if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts index 816542027f8..d4febb0cc77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts @@ -81,11 +81,7 @@ export function useContextManagement({ message, initialContexts }: UseContextMan // `(^|\s)` boundary still matches the (regular) space or start that // precedes it, then the literal sentinel. const prefix = - c.kind === 'skill' || c.kind === 'mcp' - ? SKILL_CHIP_TRIGGER - : c.kind === 'slash_command' - ? '/' - : '@' + c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' const tokenPattern = new RegExp( `(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])` ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts index cc5348690f2..5a7d866ee64 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts @@ -61,7 +61,7 @@ export function useMentionTokens({ // Find matching context to determine if it's a slash command const matchingContext = selectedContexts.find((c) => c.label === label) const prefix = - matchingContext?.kind === 'skill' || matchingContext?.kind === 'mcp' + matchingContext?.kind === 'skill' ? SKILL_CHIP_TRIGGER : matchingContext?.kind === 'slash_command' ? '/' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts index 32aaf9aa159..04dbf433d62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils.ts @@ -45,11 +45,7 @@ export function extractContextTokens(contexts: ChatContext[]): string[] { .filter((c) => c.kind !== 'current_workflow' && c.label) .map((c) => { const prefix = - c.kind === 'skill' || c.kind === 'mcp' - ? SKILL_CHIP_TRIGGER - : c.kind === 'slash_command' - ? '/' - : '@' + c.kind === 'skill' ? SKILL_CHIP_TRIGGER : c.kind === 'slash_command' ? '/' : '@' return `${prefix}${c.label}` }) } @@ -189,7 +185,6 @@ type LogsContext = Extract type IntegrationContext = Extract type SlashCommandContext = Extract type SkillContext = Extract -type McpContext = Extract /** * Checks if two contexts of the same kind are equal by their ID fields. @@ -249,10 +244,6 @@ export function areContextsEqual(c: ChatContext, context: ChatContext): boolean const ctx = context as SkillContext return c.skillId === ctx.skillId } - case 'mcp': { - const ctx = context as McpContext - return c.serverId === ctx.serverId - } default: return false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx index 4e51a715a66..a61e9e35f3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/document-tag-entry/document-tag-entry.tsx @@ -21,7 +21,6 @@ import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/ import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' @@ -99,14 +98,22 @@ export function DocumentTagEntry({ const currentValue = isPreview ? previewValue : storeValue - const parseTags = (tagValue: unknown): DocumentTag[] => - parseJsonArrayValue(tagValue).map((t) => ({ - ...t, - fieldType: t.fieldType || 'text', - collapsed: t.collapsed ?? false, - })) + const parseTags = (tagValue: string | null): DocumentTag[] => { + if (!tagValue) return [] + try { + const parsed = JSON.parse(tagValue) + if (!Array.isArray(parsed)) return [] + return parsed.map((t: DocumentTag) => ({ + ...t, + fieldType: t.fieldType || 'text', + collapsed: t.collapsed ?? false, + })) + } catch { + return [] + } + } - const parsedTags = parseTags(currentValue) + const parsedTags = parseTags(currentValue || null) const tags: DocumentTag[] = parsedTags.length > 0 ? parsedTags : [createDefaultTag()] const isReadOnly = isPreview || disabled diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx index ba68be1525f..db9ee31e574 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/knowledge-tag-filters/knowledge-tag-filters.tsx @@ -21,7 +21,6 @@ import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/compon import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input' -import { parseJsonArrayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' @@ -101,16 +100,24 @@ export function KnowledgeTagFilters({ disabled, }) - const parseFilters = (filterValue: unknown): TagFilter[] => - parseJsonArrayValue(filterValue).map((f) => ({ - ...f, - fieldType: f.fieldType || 'text', - operator: f.operator || 'eq', - collapsed: f.collapsed ?? false, - })) + const parseFilters = (filterValue: string | null): TagFilter[] => { + if (!filterValue) return [] + try { + const parsed = JSON.parse(filterValue) + if (!Array.isArray(parsed)) return [] + return parsed.map((f: TagFilter) => ({ + ...f, + fieldType: f.fieldType || 'text', + operator: f.operator || 'eq', + collapsed: f.collapsed ?? false, + })) + } catch { + return [] + } + } const currentValue = isPreview ? previewValue : storeValue - const parsedFilters = parseFilters(currentValue) + const parsedFilters = parseFilters(currentValue || null) const filters: TagFilter[] = parsedFilters.length > 0 ? parsedFilters : [createDefaultFilter()] const isReadOnly = isPreview || disabled diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.test.ts deleted file mode 100644 index 1c4304512e1..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { parseJsonArrayValue } from './utils' - -interface TagFilter { - id: string - tagName: string -} - -describe('parseJsonArrayValue', () => { - it('parses a JSON string array, the shape edit_workflow now persists', () => { - const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }] - - expect(parseJsonArrayValue(JSON.stringify(filters))).toEqual(filters) - }) - - // Rows written by builds predating the edit_workflow stringify fix still hold raw arrays. - it('passes through an already-parsed array', () => { - const filters: TagFilter[] = [{ id: 'f1', tagName: 'Department' }] - - expect(parseJsonArrayValue(filters)).toEqual(filters) - }) - - it.each([ - ['null', null], - ['undefined', undefined], - ['an empty string', ''], - ])('returns an empty array for %s', (_label, value) => { - expect(parseJsonArrayValue(value)).toEqual([]) - }) - - it.each([ - ['a malformed JSON string', '{not json'], - ['a JSON string parsing to null', 'null'], - ['a JSON string parsing to an object', '{"a":1}'], - ['a JSON string parsing to a number', '5'], - ['a bare object', { a: 1 }], - ])('returns an empty array rather than throwing for %s', (_label, value) => { - expect(parseJsonArrayValue(value)).toEqual([]) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts index d5d513a7b3c..1812992214f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts @@ -16,30 +16,3 @@ export function resolvePreviewContextValue(raw: unknown): unknown { } return raw } - -/** - * Parses a sub-block value that may be stored as a JSON string or as an already-parsed array. - * - * @remarks - * These sub-blocks contract on a JSON string, which is what their components write. Copilot's - * `edit_workflow` persisted them as raw arrays until it was fixed to re-serialize, so rows - * written by older builds still hold an array. Both shapes are accepted on read. - * - * The element type is asserted, not validated -- callers are responsible for defaulting any - * fields a malformed entry may be missing. - * - * @param value - The stored sub-block value, of unknown shape - * @returns The parsed array, or `[]` when the value is absent, unparseable, or not an array - */ -export function parseJsonArrayValue(value: unknown): T[] { - if (!value) return [] - let parsed: unknown = value - if (typeof value === 'string') { - try { - parsed = JSON.parse(value) - } catch { - return [] - } - } - return Array.isArray(parsed) ? (parsed as T[]) : [] -} diff --git a/apps/sim/blocks/blocks/mothership.test.ts b/apps/sim/blocks/blocks/mothership.test.ts deleted file mode 100644 index 8f192ac1f56..00000000000 --- a/apps/sim/blocks/blocks/mothership.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { AgentBlock } from '@/blocks/blocks/agent' -import { MothershipBlock } from '@/blocks/blocks/mothership' - -describe('MothershipBlock', () => { - it.each(['tools', 'skills'] as const)( - 'uses the same primary %s input configuration as the Agent block', - (id) => { - const agentInput = AgentBlock.subBlocks.find((subBlock) => subBlock.id === id) - const mothershipInput = MothershipBlock.subBlocks.find((subBlock) => subBlock.id === id) - - expect(mothershipInput).toEqual({ - id, - title: agentInput?.title, - type: agentInput?.type, - defaultValue: agentInput?.defaultValue, - }) - } - ) -}) diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index 9349dfbb07d..d20aaf7fa7a 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -20,7 +20,7 @@ export const MothershipBlock: BlockConfig = { name: 'Sim', description: 'Talk to Sim', longDescription: - 'The Sim block sends messages to Sim, which has access to subagents, integration tools, and workspace context. Use it to perform complex multi-step reasoning, cross-service queries, or any task that benefits from the full Sim intelligence within a workflow.', + 'The Sim block sends messages to Sim, which has access to subagents, integration tools, memory, and workspace context. Use it to perform complex multi-step reasoning, cross-service queries, or any task that benefits from the full Sim intelligence within a workflow.', bestPractices: ` - Use for tasks that require multi-step reasoning, tool use, or cross-service coordination. - Sim picks its own model and tools internally — you only provide a prompt. @@ -60,18 +60,6 @@ export const MothershipBlock: BlockConfig = { mode: 'advanced', required: false, }, - { - id: 'tools', - title: 'Tools', - type: 'tool-input', - defaultValue: [], - }, - { - id: 'skills', - title: 'Skills', - type: 'skill-input', - defaultValue: [], - }, ], tools: { access: [], @@ -89,8 +77,6 @@ export const MothershipBlock: BlockConfig = { type: 'file', description: 'Files to send to Sim as attachments', }, - tools: { type: 'json', description: 'MCP tools available to Sim for this request' }, - skills: { type: 'json', description: 'Skills activated for this request' }, }, outputs: { content: { type: 'string', description: 'Generated response content' }, diff --git a/apps/sim/blocks/registry-maps.minimal.ts b/apps/sim/blocks/registry-maps.minimal.ts index 29d6a0c3875..9c43a7b948b 100644 --- a/apps/sim/blocks/registry-maps.minimal.ts +++ b/apps/sim/blocks/registry-maps.minimal.ts @@ -9,7 +9,6 @@ import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { FileV5Block } from '@/blocks/blocks/file' import { FunctionBlock } from '@/blocks/blocks/function' import { GenericWebhookBlock } from '@/blocks/blocks/generic_webhook' -import { GmailV2Block } from '@/blocks/blocks/gmail' import { GuardrailsBlock } from '@/blocks/blocks/guardrails' import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop' import { ImapBlock } from '@/blocks/blocks/imap' @@ -41,9 +40,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' * ~268. The set is drawn from the canonical, toolbar-visible blocks * (`category: 'blocks'` / `'triggers'`, not `hideFromToolbar`, always the latest * version — never a superseded one). The ~247 `category: 'tools'` integrations - * are excluded (that is the heavy graph minimal mode exists to skip) — except - * `slack`/`slack_v2` and `gmail_v2`, kept as the everyday integrations (their - * tools are likewise included in `tools/registry.minimal.ts`) — and so are + * are excluded (that is the heavy graph minimal mode exists to skip), and so are * a few heavy or rarely-core-dev blocks pruned by hand: `mothership` and `pi` * (chunkiest configs), the media blocks `tts` / `stt_v2` / `image_generator_v2` * / `video_generator_v3`, and the `circleback` meeting-notetaker integration @@ -61,7 +58,6 @@ export const BLOCK_REGISTRY: Record = { evaluator: EvaluatorBlock, file_v5: FileV5Block, function: FunctionBlock, - gmail_v2: GmailV2Block, generic_webhook: GenericWebhookBlock, guardrails: GuardrailsBlock, human_in_the_loop: HumanInTheLoopBlock, diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts index e5e74ba4222..fdb2cb18c2b 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.test.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -20,8 +20,8 @@ vi.mock('drizzle-orm', () => ({ import { resolveSkillContent } from './skills-resolver' -// resolveSkillContent is the shared resolver invoked when a workflow agent block -// calls load_skill. +// resolveSkillContent is the shared resolver invoked when the mothership calls +// load_user_skill (and when a workflow agent block calls load_skill). describe('resolveSkillContent', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 364491017b3..bd83bb10a92 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -280,49 +280,6 @@ describe('MothershipBlockHandler', () => { expect(mockGenerateId).toHaveBeenCalledTimes(2) }) - it('forwards only enabled MCP tools and selected skills', async () => { - mockGenerateId - .mockReturnValueOnce('chat-uuid') - .mockReturnValueOnce('message-uuid') - .mockReturnValueOnce('request-uuid') - fetchMock.mockResolvedValue( - new Response(JSON.stringify({ content: 'done', toolCalls: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - await handler.execute(context, block, { - prompt: 'Use my tools', - tools: [ - { - type: 'mcp', - title: 'Search', - usageControl: 'auto', - params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, - schema: { type: 'object', properties: { query: { type: 'string' } } }, - }, - { - type: 'mcp', - usageControl: 'none', - params: { serverId: 'mcp-server-1', toolName: 'disabled' }, - }, - { type: 'gmail', operation: 'gmail_send' }, - ], - skills: [{ skillId: 'skill-1', name: 'sales-playbook' }], - }) - - const [, options] = fetchMock.mock.calls[0] as [string, RequestInit] - const body = JSON.parse(String(options.body)) - expect(body.mcpTools).toEqual([ - expect.objectContaining({ - type: 'mcp', - params: expect.objectContaining({ serverId: 'mcp-server-1', toolName: 'search' }), - }), - ]) - expect(body.contexts).toEqual([{ kind: 'skill', skillId: 'skill-1', label: 'sales-playbook' }]) - }) - it('consumes mothership execute heartbeat streams until the final result', async () => { mockGenerateId.mockReturnValueOnce('chat-uuid') mockGenerateId.mockReturnValueOnce('message-uuid') diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 866446b2761..3246fbf4cb5 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -348,28 +348,6 @@ export class MothershipBlockHandler implements BlockHandler { const messageId = generateId() const requestId = generateId() const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId) - const mcpTools = Array.isArray(inputs.tools) - ? inputs.tools.filter( - (tool: Record) => - tool.type === 'mcp' && - tool.usageControl !== 'none' && - typeof (tool.params as Record | undefined)?.serverId === 'string' && - typeof (tool.params as Record | undefined)?.toolName === 'string' - ) - : [] - const skillContexts = Array.isArray(inputs.skills) - ? inputs.skills.flatMap((skill: Record) => - typeof skill.skillId === 'string' && skill.skillId - ? [ - { - kind: 'skill', - skillId: skill.skillId, - label: typeof skill.name === 'string' ? skill.name : skill.skillId, - }, - ] - : [] - ) - : [] const url = buildAPIUrl('/api/mothership/execute') const headers = await buildAuthHeaders(ctx.userId) @@ -390,8 +368,6 @@ export class MothershipBlockHandler implements BlockHandler { messageId, requestId, ...(fileAttachments && { fileAttachments }), - ...(mcpTools.length > 0 ? { mcpTools } : {}), - ...(skillContexts.length > 0 ? { contexts: skillContexts } : {}), ...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}), ...(ctx.executionId ? { executionId: ctx.executionId } : {}), } @@ -404,8 +380,6 @@ export class MothershipBlockHandler implements BlockHandler { executionId: ctx.executionId, chatId, fileAttachmentCount: fileAttachments?.length ?? 0, - mcpToolCount: mcpTools.length, - skillCount: skillContexts.length, }) const abortController = new AbortController() diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 606dc0020e3..1d802c13d93 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -675,12 +675,12 @@ export function useCreateMothershipChat(workspaceId?: string) { async function forkChat(params: { chatId: string upToMessageId: string -}): Promise<{ id: string; failedFileCopies?: number }> { +}): Promise<{ id: string }> { const data = await requestJson(forkMothershipChatContract, { params: { chatId: params.chatId }, body: { upToMessageId: params.upToMessageId }, }) - return { id: data.id, failedFileCopies: data.failedFileCopies } + return { id: data.id } } export function useForkMothershipChat(workspaceId?: string) { diff --git a/apps/sim/hooks/queries/utils/workflow-cache.test.ts b/apps/sim/hooks/queries/utils/workflow-cache.test.ts index 3db8179f1a7..a3ccf8da645 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.test.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { getQueryDataMock } = vi.hoisted(() => ({ @@ -14,23 +13,7 @@ vi.mock('@/app/_shell/providers/get-query-client', () => ({ })), })) -import { - getWorkflowById, - getWorkflows, - removeWorkflowFromActiveCache, -} from '@/hooks/queries/utils/workflow-cache' -import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' - -function workflow(id: string): WorkflowMetadata { - return { - id, - name: id, - lastModified: new Date(0), - createdAt: new Date(0), - sortOrder: 0, - } -} +import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' describe('getWorkflows', () => { beforeEach(() => { @@ -61,17 +44,3 @@ describe('getWorkflows', () => { expect(getWorkflowById('ws-1', 'missing')).toBeUndefined() }) }) - -describe('removeWorkflowFromActiveCache', () => { - it('removes the deleted workflow immediately and returns the rollback snapshot', () => { - const queryClient = new QueryClient() - const key = workflowKeys.list('ws-1', 'active') - const initial = [workflow('wf-1'), workflow('wf-2')] - queryClient.setQueryData(key, initial) - - const snapshot = removeWorkflowFromActiveCache(queryClient, 'ws-1', 'wf-1') - - expect(snapshot).toEqual(initial) - expect(queryClient.getQueryData(key)).toEqual([workflow('wf-2')]) - }) -}) diff --git a/apps/sim/hooks/queries/utils/workflow-cache.ts b/apps/sim/hooks/queries/utils/workflow-cache.ts index e12c802821d..c810a236f87 100644 --- a/apps/sim/hooks/queries/utils/workflow-cache.ts +++ b/apps/sim/hooks/queries/utils/workflow-cache.ts @@ -1,4 +1,3 @@ -import type { QueryClient } from '@tanstack/react-query' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -28,21 +27,3 @@ export function getWorkflowById( ): WorkflowMetadata | undefined { return getWorkflows(workspaceId, scope).find((workflow) => workflow.id === workflowId) } - -/** - * Removes a workflow from the active-list cache immediately and returns the - * previous value for callers that need rollback. Shared by user-initiated - * deletion and streamed Mothership resource removals. - */ -export function removeWorkflowFromActiveCache( - queryClient: QueryClient, - workspaceId: string, - workflowId: string -): WorkflowMetadata[] | undefined { - const key = workflowKeys.list(workspaceId, 'active') - const snapshot = queryClient.getQueryData(key) - queryClient.setQueryData(key, (current) => - (current ?? []).filter((workflow) => workflow.id !== workflowId) - ) - return snapshot -} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index fcffbcc24ea..cba6a50994a 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -32,7 +32,7 @@ import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-enve import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { getWorkflows, removeWorkflowFromActiveCache } from '@/hooks/queries/utils/workflow-cache' +import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { getWorkflowListQueryOptions, @@ -534,10 +534,13 @@ export function useDeleteWorkflowMutation() { queryKey: workflowKeys.list(variables.workspaceId, 'active'), }) - const snapshot = removeWorkflowFromActiveCache( - queryClient, - variables.workspaceId, - variables.workflowId + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => (old ?? []).filter((w) => w.id !== variables.workflowId) ) return { snapshot } diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 239c420431f..0165c512bd3 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -68,35 +68,6 @@ const mothershipExecuteFileAttachmentSchema = z }) .passthrough() -type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } -const jsonValueSchema: z.ZodType = z.lazy(() => - z.union([ - z.string(), - z.number(), - z.boolean(), - z.null(), - z.array(jsonValueSchema), - z.record(z.string(), jsonValueSchema), - ]) -) -const mothershipExecuteMcpInputSchema = z.record(z.string(), jsonValueSchema) - -const mothershipExecuteMcpToolSchema = z - .object({ - type: z.literal('mcp'), - usageControl: z.enum(['auto', 'force', 'none']).optional(), - title: z.string().optional(), - schema: mothershipExecuteMcpInputSchema.optional(), - params: z - .object({ - serverId: z.string().min(1), - toolName: z.string().min(1), - serverName: z.string().optional(), - }) - .passthrough(), - }) - .passthrough() - export const mothershipExecuteBodySchema = z.object({ messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'), responseFormat: z.any().optional(), @@ -112,7 +83,6 @@ export const mothershipExecuteBodySchema = z.object({ * captured contexts must reach the run without a live client. */ contexts: z.array(scheduleContextSchema).optional(), - mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), userMetadata: z @@ -306,13 +276,6 @@ export const forkMothershipChatContract = defineRouteContract({ schema: z.object({ success: z.literal(true), id: z.string(), - /** - * Present (and > 0) when some file blobs could not be byte-copied: the - * new chat exists and its transcript references those copies, but their - * bytes are missing (blob copies are best-effort, post-transaction). - * Callers should surface a warning. - */ - failedFileCopies: z.number().optional(), }), }, }) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index a1de918b1df..6cd7f11b47e 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -252,7 +252,6 @@ export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), workspaceId: workspaceIdSchema, callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), }) export const authorizeOAuth2Contract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index c7c4fcea852..ac7ba92e790 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -68,17 +68,9 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi case MothershipStreamV1EventType.text: if (block.lane === 'subagent') { if (block.channel === 'thinking') { - return { - type: ContentBlockType.subagent_thinking, - content: block.content, - ...(block.agent ? { subagent: block.agent } : {}), - } - } - return { - type: ContentBlockType.subagent_text, - content: block.content, - ...(block.agent ? { subagent: block.agent } : {}), + return { type: ContentBlockType.subagent_thinking, content: block.content } } + return { type: ContentBlockType.subagent_text, content: block.content } } if (block.channel === 'thinking') { return { type: ContentBlockType.thinking, content: block.content } @@ -126,9 +118,6 @@ function toDisplayContexts( ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), })) } diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/copilot/chat/effective-transcript.test.ts index 10c74da0545..285743d37ac 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.test.ts @@ -6,18 +6,13 @@ import { describe, expect, it } from 'vitest' import { buildEffectiveChatTranscript, getLiveAssistantMessageId, - isLiveAssistantMessageId, } from '@/lib/copilot/chat/effective-transcript' import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, - MothershipStreamV1RunKind, MothershipStreamV1SessionKind, - MothershipStreamV1SpanLifecycleEvent, - MothershipStreamV1SpanPayloadKind, MothershipStreamV1TextChannel, - MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' @@ -154,7 +149,7 @@ describe('buildEffectiveChatTranscript', () => { ) }) - it('never surfaces thinking-channel text: a thinking-only stream stays a placeholder', () => { + it('does not duplicate thinking-only text into a second assistant block', () => { const result = buildEffectiveChatTranscript({ messages: [buildUserMessage('stream-1', 'Hello')], activeStreamId: 'stream-1', @@ -180,51 +175,15 @@ describe('buildEffectiveChatTranscript', () => { expect(result).toHaveLength(2) expect(result[1]).toEqual( expect.objectContaining({ - id: getLiveAssistantMessageId('stream-1'), - role: 'assistant', - content: '', - }) - ) - expect(JSON.stringify(result[1])).not.toContain('Internal reasoning') - }) - - it('keeps assistant text while dropping interleaved thinking chunks', () => { - const textEvent = (seq: number, channel: MothershipStreamV1TextChannel, text: string) => - toBatchEvent(seq, { - v: 1, - seq, - ts: '2026-04-15T12:00:01.000Z', - type: MothershipStreamV1EventType.text, - stream: { streamId: 'stream-1' }, - payload: { channel, text }, - }) - const result = buildEffectiveChatTranscript({ - messages: [buildUserMessage('stream-1', 'Hello')], - activeStreamId: 'stream-1', - streamSnapshot: { - events: [ - textEvent(1, MothershipStreamV1TextChannel.thinking, '**Planning**\n\nHidden reasoning.'), - textEvent(2, MothershipStreamV1TextChannel.assistant, 'Visible answer.'), - textEvent(3, MothershipStreamV1TextChannel.thinking, 'More hidden reasoning.'), - ], - previewSessions: [], - status: 'active', - }, - }) - - expect(result).toHaveLength(2) - expect(result[1]).toEqual( - expect.objectContaining({ - content: 'Visible answer.', + content: 'Internal reasoning', contentBlocks: [ expect.objectContaining({ type: MothershipStreamV1EventType.text, - content: 'Visible answer.', + content: 'Internal reasoning', }), ], }) ) - expect(JSON.stringify(result[1])).not.toContain('reasoning') }) it('treats user-cancelled tool results as cancelled', () => { @@ -269,81 +228,6 @@ describe('buildEffectiveChatTranscript', () => { ]) }) - it('pairs a scoped compaction inside the owning subagent during stream replay', () => { - const scope = { - lane: 'subagent' as const, - parentToolCallId: 'tc-workflow', - spanId: 'span-workflow', - parentSpanId: 'span-superagent', - agentId: 'superagent', - } - const stream = { streamId: 'stream-1' } - const result = buildEffectiveChatTranscript({ - messages: [buildUserMessage('stream-1', 'Hello')], - activeStreamId: 'stream-1', - streamSnapshot: { - events: [ - toBatchEvent(1, { - v: 1, - seq: 1, - ts: '2026-04-15T12:00:01.000Z', - type: MothershipStreamV1EventType.span, - stream, - scope, - payload: { - kind: MothershipStreamV1SpanPayloadKind.subagent, - event: MothershipStreamV1SpanLifecycleEvent.start, - agent: 'workflow', - data: { tool_call_id: 'tc-workflow' }, - }, - }), - toBatchEvent(2, { - v: 1, - seq: 2, - ts: '2026-04-15T12:00:02.000Z', - type: MothershipStreamV1EventType.run, - stream, - scope, - payload: { kind: MothershipStreamV1RunKind.compaction_start }, - }), - toBatchEvent(3, { - v: 1, - seq: 3, - ts: '2026-04-15T12:00:03.000Z', - type: MothershipStreamV1EventType.run, - stream, - scope, - payload: { - kind: MothershipStreamV1RunKind.compaction_done, - data: { summary_chars: 42 }, - }, - }), - ], - previewSessions: [], - status: 'active', - }, - }) - - const compactions = result[1]?.contentBlocks?.filter( - (block) => block.type === MothershipStreamV1EventType.tool - ) - expect(compactions).toHaveLength(1) - expect(compactions?.[0]).toEqual( - expect.objectContaining({ - parentToolCallId: 'tc-workflow', - spanId: 'span-workflow', - parentSpanId: 'span-superagent', - toolCall: expect.objectContaining({ - id: 'compaction_2', - name: 'context_compaction', - calledBy: 'workflow', - display: { title: 'Summarizing context' }, - state: MothershipStreamV1ToolOutcome.success, - }), - }) - ) - }) - it('materializes a cancelled assistant tail when the stream ends before persistence', () => { const result = buildEffectiveChatTranscript({ messages: [buildUserMessage('stream-1', 'Hello')], @@ -377,99 +261,3 @@ describe('buildEffectiveChatTranscript', () => { ) }) }) - -describe('isLiveAssistantMessageId', () => { - it('recognizes the synthetic live-assistant id and nothing else', () => { - expect(isLiveAssistantMessageId(getLiveAssistantMessageId('stream-1'))).toBe(true) - expect(isLiveAssistantMessageId('f620fceb-4e9d-4e7f-ab7f-890a2a823564')).toBe(false) - expect(isLiveAssistantMessageId('')).toBe(false) - }) -}) - -describe('tool ownership is call-frame authoritative', () => { - const toolEvent = ( - seq: number, - phase: 'call' | 'result', - toolCallId: string, - scope?: Record - ): StreamBatchEvent => - toBatchEvent(seq, { - v: 1, - seq, - ts: '2026-04-15T12:00:01.000Z', - type: MothershipStreamV1EventType.tool, - stream: { streamId: 'stream-1' }, - payload: - phase === 'call' - ? { phase: 'call', toolCallId, toolName: 'read', arguments: { path: 'a.md' } } - : { phase: 'result', toolCallId, toolName: 'read', success: true, output: 'ok' }, - ...(scope ? { scope } : {}), - // double-cast-allowed: synthetic test envelope; the reducer reads only the fields set here - } as unknown as StreamBatchEvent['event']) - - const superagentScope = { - lane: 'subagent', - agentId: 'superagent', - parentToolCallId: 'dispatch-1', - spanId: 'S1', - } - - const ownership = (result: ReturnType) => { - const blocks = (result[1].contentBlocks ?? []) as Array> - const tool = blocks.find((b) => b.type === MothershipStreamV1EventType.tool) - const tc = tool?.toolCall as Record | undefined - return { - calledBy: tc?.calledBy, - parentToolCallId: tool?.parentToolCallId, - spanId: tool?.spanId, - } - } - - it('an unscoped main call clears provisional subagent attribution', () => { - // The observed dev bug: a mis-scoped replayed result seeded the main - // read under Superagent, and nothing could ever move it back. - const result = buildEffectiveChatTranscript({ - messages: [buildUserMessage('stream-1', 'Hello')], - activeStreamId: 'stream-1', - streamSnapshot: { - events: [toolEvent(1, 'result', 'fc_1', superagentScope), toolEvent(2, 'call', 'fc_1')], - previewSessions: [], - status: 'active', - }, - }) - const own = ownership(result) - expect(own.calledBy).toBeUndefined() - expect(own.parentToolCallId).toBeUndefined() - expect(own.spanId).toBeUndefined() - }) - - it('a later mis-scoped result cannot re-parent a settled main tool', () => { - const result = buildEffectiveChatTranscript({ - messages: [buildUserMessage('stream-1', 'Hello')], - activeStreamId: 'stream-1', - streamSnapshot: { - events: [toolEvent(1, 'call', 'fc_1'), toolEvent(2, 'result', 'fc_1', superagentScope)], - previewSessions: [], - status: 'active', - }, - }) - const own = ownership(result) - expect(own.calledBy).toBeUndefined() - expect(own.parentToolCallId).toBeUndefined() - }) - - it('a genuinely scoped subagent call keeps its ownership', () => { - const result = buildEffectiveChatTranscript({ - messages: [buildUserMessage('stream-1', 'Hello')], - activeStreamId: 'stream-1', - streamSnapshot: { - events: [toolEvent(1, 'call', 'fc_2', superagentScope), toolEvent(2, 'result', 'fc_2')], - previewSessions: [], - status: 'active', - }, - }) - const own = ownership(result) - expect(own.calledBy).toBe('superagent') - expect(own.parentToolCallId).toBe('dispatch-1') - }) -}) diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index 6594e0f8c7a..ae971047208 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -9,16 +9,12 @@ import { MothershipStreamV1SessionKind, MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, - MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/copilot/generated/mothership-stream-v1' import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' -import { - CONTEXT_COMPACTION_DISPLAY_TITLE, - getToolDisplayTitle, -} from '@/lib/copilot/tools/tool-display' +import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' interface StreamSnapshotLike { events: StreamBatchEvent[] @@ -38,16 +34,6 @@ export function getLiveAssistantMessageId(streamId: string): string { return `live-assistant:${streamId}` } -/** - * True for the synthetic id of a streaming/just-streamed assistant message. - * These ids exist only in the client's effective transcript — never in the - * persisted one — so message-scoped server actions (e.g. fork) must not be - * offered until the transcript refetch swaps in the persisted message id. - */ -export function isLiveAssistantMessageId(messageId: string): boolean { - return messageId.startsWith('live-assistant:') -} - function asPayloadRecord(value: unknown): Record | undefined { return isRecordLike(value) ? value : undefined } @@ -119,7 +105,7 @@ function buildLiveAssistantMessage(params: { const subagentBySpanId = new Map() let activeSubagent: string | undefined let activeSubagentParentToolCallId: string | undefined - const activeCompactionIdByLane = new Map() + let activeCompactionId: string | undefined let runningText = '' let lastContentSource: 'main' | 'subagent' | null = null let requestId: string | undefined @@ -133,6 +119,7 @@ function buildLiveAssistantMessage(params: { parentToolCallId: string | undefined, spanId?: string ): string | undefined => { + if (agentId) return agentId if (spanId) { const scoped = subagentBySpanId.get(spanId) if (scoped) return scoped @@ -141,7 +128,7 @@ function buildLiveAssistantMessage(params: { const scoped = subagentByParentToolCallId.get(parentToolCallId) if (scoped) return scoped } - return agentId + return undefined } const resolveParentForSubagentBlock = ( @@ -152,17 +139,6 @@ function buildLiveAssistantMessage(params: { return scopedParent } - // Tool ownership (calledBy / parent / span identity) is CALL-FRAME - // authoritative: once a call frame for a tool id has been reduced, later - // scoped results or replayed duplicates must not re-parent the tool. Before - // a call frame arrives, ownership stays provisional (result-first replay - // arrival is legal) and the call frame settles it — including CLEARING - // stale subagent attribution when the call is main-lane (unscoped). Without - // the clear, one mis-scoped replayed event pinned main tools under a - // subagent (observed: Sim's reads rendered under Superagent) with no later - // event able to correct it. - const toolOwnershipSettled = new Set() - const ensureToolBlock = (input: { toolCallId: string toolName: string @@ -174,11 +150,7 @@ function buildLiveAssistantMessage(params: { params?: Record result?: { success: boolean; output?: unknown; error?: string } state?: string - isCallFrame?: boolean }): RawPersistedBlock => { - const ownershipWritable = - input.isCallFrame === true || !toolOwnershipSettled.has(input.toolCallId) - if (input.isCallFrame) toolOwnershipSettled.add(input.toolCallId) const existingIndex = toolIndexById.get(input.toolCallId) if (existingIndex !== undefined) { const existing = blocks[existingIndex] @@ -190,7 +162,7 @@ function buildLiveAssistantMessage(params: { state: input.state ?? (typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'), - ...(ownershipWritable && input.calledBy ? { calledBy: input.calledBy } : {}), + ...(input.calledBy ? { calledBy: input.calledBy } : {}), ...(input.params ? { params: input.params } : {}), ...(input.result ? { result: input.result } : {}), ...(input.displayTitle @@ -203,21 +175,9 @@ function buildLiveAssistantMessage(params: { ? { display: existingToolCall.display } : {}), } - if (ownershipWritable) { - if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId - if (input.spanId) existing.spanId = input.spanId - if (input.parentSpanId) existing.parentSpanId = input.parentSpanId - if (input.isCallFrame && !input.calledBy) { - // Authoritative main-lane call: clear any provisionally-seeded - // subagent attribution so the tool renders under Sim, not the - // forwarding caller. - const tc = asPayloadRecord(existing.toolCall) - if (tc) tc.calledBy = undefined - existing.parentToolCallId = undefined - existing.spanId = undefined - existing.parentSpanId = undefined - } - } + if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId + if (input.spanId) existing.spanId = input.spanId + if (input.parentSpanId) existing.parentSpanId = input.parentSpanId return existing } @@ -270,11 +230,6 @@ function buildLiveAssistantMessage(params: { ...(scopedSpanId ? { spanId: scopedSpanId } : {}), ...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}), } - const compactionLaneKey = scopedSpanId - ? `span:${scopedSpanId}` - : scopedParentToolCallId - ? `parent:${scopedParentToolCallId}` - : 'main' switch (parsed.type) { case MothershipStreamV1EventType.session: { @@ -294,14 +249,6 @@ function buildLiveAssistantMessage(params: { if (!chunk) { continue } - // Reasoning is never rendered or persisted (the stream reducer and the - // turn model both key on the channel; buildPersistedAssistantMessage - // strips it). This snapshot-derived converter must not resurrect it as - // visible prose — skip before block append AND runningText so thinking - // never leaks into the live-assistant message's content either. - if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) { - continue - } const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main' const needsBoundaryNewline = lastContentSource !== null && @@ -362,7 +309,6 @@ function buildLiveAssistantMessage(params: { ), params: isRecordLike(payload.arguments) ? payload.arguments : undefined, state: typeof payload.status === 'string' ? payload.status : 'executing', - isCallFrame: payload.phase === MothershipStreamV1ToolPhase.call, }) continue } @@ -429,39 +375,23 @@ function buildLiveAssistantMessage(params: { } case MothershipStreamV1EventType.run: { if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_start) { - const compactionId = `compaction_${entry.eventId}` - activeCompactionIdByLane.set(compactionLaneKey, compactionId) - const parentForBlock = resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) + activeCompactionId = `compaction_${entry.eventId}` ensureToolBlock({ - toolCallId: compactionId, + toolCallId: activeCompactionId, toolName: 'context_compaction', - calledBy: scopedSubagent, - ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), - ...spanIdentity, - displayTitle: CONTEXT_COMPACTION_DISPLAY_TITLE, + displayTitle: 'Compacting context...', state: 'executing', }) continue } if (parsed.payload.kind === MothershipStreamV1RunKind.compaction_done) { - const compactionId = - activeCompactionIdByLane.get(compactionLaneKey) ?? `compaction_${entry.eventId}` - activeCompactionIdByLane.delete(compactionLaneKey) - const parentForBlock = resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) + const compactionId = activeCompactionId ?? `compaction_${entry.eventId}` + activeCompactionId = undefined ensureToolBlock({ toolCallId: compactionId, toolName: 'context_compaction', - calledBy: scopedSubagent, - ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), - ...spanIdentity, - displayTitle: CONTEXT_COMPACTION_DISPLAY_TITLE, + displayTitle: 'Compacted context', state: MothershipStreamV1ToolOutcome.success, }) } diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts b/apps/sim/lib/copilot/chat/fork-chat-files.test.ts deleted file mode 100644 index 30092e59960..00000000000 --- a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGenerateKey, mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({ - mockGenerateKey: vi.fn(), - mockDownloadFile: vi.fn(), - mockUploadFile: vi.fn(), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - generateWorkspaceFileKey: mockGenerateKey, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: mockDownloadFile, - uploadFile: mockUploadFile, -})) - -import { - executeChatFileBlobCopies, - type ForkableChatFileRow, - filterForkableChatFiles, - planChatFileCopies, -} from '@/lib/copilot/chat/fork-chat-files' - -const NOW = new Date('2026-07-08T00:00:00.000Z') - -function makeRow(overrides: Partial = {}): ForkableChatFileRow { - return { - id: 'wf_source', - key: 'workspace/ws-1/1-cat.png', - userId: 'user-1', - workspaceId: 'ws-1', - folderId: null, - context: 'mothership', - chatId: 'chat-1', - messageId: 'msg-1', - originalName: 'cat.png', - displayName: 'cat.png', - contentType: 'image/png', - size: 100, - deletedAt: null, - uploadedAt: new Date('2026-06-01T00:00:00.000Z'), - updatedAt: new Date('2026-06-01T00:00:00.000Z'), - ...overrides, - } as ForkableChatFileRow -} - -describe('planChatFileCopies', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGenerateKey.mockReturnValue('workspace/ws-1/2-cat.png') - }) - - it('copies a row under the fork with a fresh id + key and the SAME message_id', async () => { - const inserted: Array> = [] - const tx = { - insert: () => ({ - values: async (v: Record | Record[]) => { - inserted.push(...(Array.isArray(v) ? v : [v])) - }, - }), - } - - const { idMap, keyMap, blobTasks } = await planChatFileCopies({ - tx: tx as never, - rows: [makeRow()], - newChatId: 'chat-fork', - userId: 'user-1', - now: NOW, - }) - - expect(inserted).toHaveLength(1) - const copy = inserted[0] - expect(copy.id).not.toBe('wf_source') - expect(String(copy.id)).toMatch(/^wf_/) - expect(copy.key).toBe('workspace/ws-1/2-cat.png') - expect(copy.chatId).toBe('chat-fork') - expect(copy.messageId).toBe('msg-1') - expect(copy.displayName).toBe('cat.png') - expect(copy.deletedAt).toBeNull() - - expect(idMap.get('wf_source')).toBe(copy.id) - expect(keyMap.get('workspace/ws-1/1-cat.png')).toBe('workspace/ws-1/2-cat.png') - expect(blobTasks).toEqual([ - { - copyId: copy.id, - sourceKey: 'workspace/ws-1/1-cat.png', - targetKey: 'workspace/ws-1/2-cat.png', - context: 'mothership', - fileName: 'cat.png', - contentType: 'image/png', - }, - ]) - }) - - it('skips legacy rows with no workspaceId instead of failing the fork', async () => { - const inserted: Array> = [] - const tx = { - insert: () => ({ - values: async (v: Record | Record[]) => { - inserted.push(...(Array.isArray(v) ? v : [v])) - }, - }), - } - - const { idMap, blobTasks } = await planChatFileCopies({ - tx: tx as never, - rows: [makeRow({ workspaceId: null })], - newChatId: 'chat-fork', - userId: 'user-1', - now: NOW, - }) - - expect(inserted).toHaveLength(0) - expect(idMap.size).toBe(0) - expect(blobTasks).toHaveLength(0) - }) -}) - -describe('executeChatFileBlobCopies', () => { - const task = { - copyId: 'wf_copy', - sourceKey: 'workspace/ws-1/1-cat.png', - targetKey: 'workspace/ws-1/2-cat.png', - context: 'mothership' as const, - fileName: 'cat.png', - contentType: 'image/png', - } - - beforeEach(() => { - vi.clearAllMocks() - mockDownloadFile.mockResolvedValue(Buffer.from('0123456789')) - mockUploadFile.mockResolvedValue(undefined) - }) - - it('copies bytes to the new key without workspace storage accounting', async () => { - const result = await executeChatFileBlobCopies([task]) - - expect(result).toEqual({ copied: 1, failed: 0, failedCopyIds: [] }) - expect(mockUploadFile).toHaveBeenCalledWith( - expect.objectContaining({ - customKey: 'workspace/ws-1/2-cat.png', - preserveKey: true, - }) - ) - // No `metadata` in the upload call — passing it would insert a second row. - expect(mockUploadFile.mock.calls[0][0].metadata).toBeUndefined() - }) - - it('is best-effort: a failed download skips the file and reports its copy id', async () => { - mockDownloadFile.mockRejectedValueOnce(new Error('blob missing')) - - const result = await executeChatFileBlobCopies([ - task, - { ...task, copyId: 'wf_copy2', targetKey: 'workspace/ws-1/3-cat.png' }, - ]) - - // The first task's download failed — its copy id comes back for row cleanup. - expect(result).toEqual({ copied: 1, failed: 1, failedCopyIds: ['wf_copy'] }) - }) - - it('copies every task even when the batch exceeds the concurrency bound', async () => { - const tasks = Array.from({ length: 9 }, (_, i) => ({ - ...task, - copyId: `wf_copy${i}`, - targetKey: `workspace/ws-1/${i}-cat.png`, - })) - - const result = await executeChatFileBlobCopies(tasks) - - expect(result.copied).toBe(9) - expect(mockUploadFile).toHaveBeenCalledTimes(9) - expect(new Set(mockUploadFile.mock.calls.map((c) => c[0].customKey)).size).toBe(9) - }) -}) - -describe('filterForkableChatFiles', () => { - it('keeps rows born in the kept slice plus NULL-birthdate legacy rows', () => { - const preCut = makeRow({ id: 'wf_pre', messageId: 'msg-1' }) - const postCut = makeRow({ id: 'wf_post', messageId: 'msg-3' }) - const legacy = makeRow({ id: 'wf_legacy', messageId: null }) - const secondPreCut = makeRow({ id: 'wf_pre2', messageId: 'msg-2' }) - - const kept = filterForkableChatFiles( - [preCut, postCut, legacy, secondPreCut], - new Set(['msg-1', 'msg-2']) - ) - - expect(kept.map((r) => r.id)).toEqual(['wf_pre', 'wf_legacy', 'wf_pre2']) - }) -}) diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.ts b/apps/sim/lib/copilot/chat/fork-chat-files.ts deleted file mode 100644 index f7dd90d0fa1..00000000000 --- a/apps/sim/lib/copilot/chat/fork-chat-files.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { workspaceFiles } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' -import { and, eq, isNull } from 'drizzle-orm' -import { mapWithConcurrency } from '@/lib/core/utils/concurrency' -import type { DbOrTx } from '@/lib/db/types' -import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' -import type { StorageContext } from '@/lib/uploads/shared/types' -import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' - -const logger = createLogger('ForkChatFiles') - -/** - * The chat-owned storage context a fork copies: user uploads (`mothership`). - * A fork is a self-contained snapshot — bytes included (every copied row gets - * a fresh storage key; live rows can't share a key because of the - * `workspace_files_key_active_unique` index, and serve/view lookups resolve by - * key) — so the new chat survives deletion of the source chat. The copied set - * is timeline-cut to the fork point ({@link filterForkableChatFiles}). - * Shared workspace `files/` (`context='workspace'`) is workspace-owned, not - * chat-owned — both chats reference it in place and it is never copied. - */ -export const FORKABLE_CHAT_FILE_CONTEXT: StorageContext = 'mothership' - -/** Max concurrent blob byte-copies during a chat fork. */ -const CHAT_BLOB_COPY_CONCURRENCY = 4 - -export type ForkableChatFileRow = typeof workspaceFiles.$inferSelect - -/** One blob byte-copy to run after the fork transaction commits. */ -export interface ChatBlobCopyTask { - /** The copied `workspace_files` row's id — used to delete the row if the blob copy fails. */ - copyId: string - sourceKey: string - targetKey: string - context: StorageContext - fileName: string - contentType: string -} - -export interface PlanChatFileCopiesResult { - /** source `workspace_files.id` -> copy id (rewrites view-URLs, attachment ids, resource ids). */ - idMap: Map - /** source storage key -> copy storage key (rewrites serve-URLs, attachment keys). */ - keyMap: Map - /** Blob duplications to run after the transaction commits. */ - blobTasks: ChatBlobCopyTask[] -} - -/** - * Every live chat-owned file row (no timeline cut): the ghost test set for the - * resource-chip rewrite and the superset a fork cuts down in memory via - * {@link filterForkableChatFiles} — one `workspace_files` read serves both. - * Also used pre-transaction to sum sizes for the storage-quota gate. - */ -export async function listForkableChatFiles( - db: DbOrTx, - chatId: string -): Promise { - return db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.chatId, chatId), - eq(workspaceFiles.context, FORKABLE_CHAT_FILE_CONTEXT), - isNull(workspaceFiles.deletedAt) - ) - ) -} - -/** - * The rows a fork copies out of the chat's owned files: those whose - * `message_id` is at-or-before the fork point (i.e. in the kept message - * slice). Rows with a NULL `message_id` — uploads that predate messageId - * stamping — are included in every fork of their chat: we can't know when - * they arrived, and copying them beats forking with broken references. Pure - * filter so the route reads `workspace_files` once per fork - * ({@link listForkableChatFiles}). - */ -export function filterForkableChatFiles( - rows: ForkableChatFileRow[], - keptMessageIds: ReadonlySet -): ForkableChatFileRow[] { - return rows.filter((row) => !row.messageId || keptMessageIds.has(row.messageId)) -} - -/** - * Insert copy rows for the kept chat-owned files under the new chat id (fresh - * `wf_` id + fresh storage key; `message_id` carries over verbatim so the copy - * matches the same message in the forked transcript; display names carry over - * verbatim because their uniqueness is per-chat and the new chat is an empty - * namespace). Returns the old->new id/key maps that drive the reference - * rewrite, plus the blob byte-copies to run post-commit. Runs inside the fork - * transaction so a failed insert rolls the whole fork back; blob I/O is - * deferred to {@link executeChatFileBlobCopies}. Modeled on the workspace-fork - * copy (`lib/workspaces/fork/copy/copy-files.ts`), adapted for chat-scoped rows. - */ -export async function planChatFileCopies(params: { - tx: DbOrTx - rows: ForkableChatFileRow[] - newChatId: string - userId: string - now: Date -}): Promise { - const { tx, rows, newChatId, userId, now } = params - const idMap = new Map() - const keyMap = new Map() - const blobTasks: ChatBlobCopyTask[] = [] - const copyRows: (typeof workspaceFiles.$inferInsert)[] = [] - - for (const row of rows) { - if (!row.workspaceId) { - logger.warn('Skipping chat file with no workspaceId during fork', { fileId: row.id }) - continue - } - const copyId = `wf_${generateShortId()}` - const targetKey = generateWorkspaceFileKey(row.workspaceId, row.originalName) - copyRows.push({ - ...row, - id: copyId, - key: targetKey, - chatId: newChatId, - userId, - deletedAt: null, - uploadedAt: now, - updatedAt: now, - }) - idMap.set(row.id, copyId) - keyMap.set(row.key, targetKey) - blobTasks.push({ - copyId, - sourceKey: row.key, - targetKey, - context: row.context as StorageContext, - fileName: row.originalName, - contentType: row.contentType, - }) - } - - // Ids and keys are generated client-side, so one multi-row insert suffices — - // no per-row round trips while the fork transaction is held open. - if (copyRows.length > 0) { - await tx.insert(workspaceFiles).values(copyRows) - } - - return { idMap, keyMap, blobTasks } -} - -/** - * Copy each planned blob to its new key, best-effort: a failed copy logs a - * warning and is skipped (the fork keeps its transcript; that one file is - * missing) rather than failing the whole fork. Runs a bounded worker pool - * ({@link CHAT_BLOB_COPY_CONCURRENCY}) — media-heavy chats must not pay 2N - * serial storage round-trips, but unbounded fan-out would buffer every file - * in memory at once. Mothership files remain excluded from workspace storage - * accounting. Failed tasks' copy-row ids are returned so the caller can delete - * the dead rows (row exists, blob doesn't) instead of leaving them listed in - * the VFS and resources with nothing behind them. - */ -export async function executeChatFileBlobCopies( - blobTasks: ChatBlobCopyTask[] -): Promise<{ copied: number; failed: number; failedCopyIds: string[] }> { - let copied = 0 - const failedCopyIds: string[] = [] - - const copyOne = async (task: ChatBlobCopyTask): Promise => { - try { - // No replay guard here, unlike the workspace-fork copy this is modeled - // on: that path persists its task list in a trigger.dev job payload - // (replayable), while these tasks exist only in this request's memory - // and target keys are freshly minted per request — a HEAD check could - // never find an earlier attempt's object. - const buffer = await downloadFile({ - key: task.sourceKey, - context: task.context, - maxBytes: MAX_FILE_SIZE, - }) - // No `metadata` here on purpose: passing it would make uploadFile insert - // its own workspace_files row (without chatId), colliding with the row - // the transaction already created for this key. - await uploadFile({ - file: buffer, - fileName: task.fileName, - contentType: task.contentType, - context: task.context, - customKey: task.targetKey, - preserveKey: true, - }) - copied += 1 - } catch (error) { - failedCopyIds.push(task.copyId) - logger.warn('Failed to copy chat file blob during fork', { - sourceKey: task.sourceKey, - targetKey: task.targetKey, - error: getErrorMessage(error), - }) - } - } - - await mapWithConcurrency(blobTasks, CHAT_BLOB_COPY_CONCURRENCY, copyOne) - - return { copied, failed: failedCopyIds.length, failedCopyIds } -} diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index f7a68910a00..06e3a288e0a 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -33,8 +33,6 @@ vi.mock('@/tools/registry', () => ({ id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail', - outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, - oauth: { required: true, provider: 'google-email' }, }, brandfetch_search: { id: 'brandfetch_search', @@ -69,13 +67,7 @@ vi.mock('@/lib/copilot/integration-tools', () => ({ getExposedIntegrationTools: vi.fn(() => [ { toolId: 'gmail_send', - config: { - id: 'gmail_send', - name: 'Gmail Send', - description: 'Send emails using Gmail', - outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, - oauth: { required: true, provider: 'google-email' }, - }, + config: { id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail' }, service: 'gmail', operation: 'send', }, @@ -162,22 +154,6 @@ describe('buildIntegrationToolSchemas', () => { expect(runTool?.executeLocally).toBe(true) }) - it('preserves operation, outputs, and OAuth discovery metadata', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) - - const toolSchemas = await buildIntegrationToolSchemas('user-metadata') - const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send') - - expect(gmailTool).toEqual( - expect.objectContaining({ - service: 'gmail', - operation: 'send', - outputs: { messageId: { type: 'string', description: 'Sent message ID' } }, - oauth: { required: true, provider: 'google-email' }, - }) - ) - }) - it('uses copilot-facing file schemas for integration tools', async () => { mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' }) @@ -198,13 +174,11 @@ describe('buildIntegrationToolSchemas', () => { const first = await buildIntegrationToolSchemas('user-cache') first[0].input_schema.mutated = true - if (first[0].outputs) first[0].outputs.mutated = true const second = await buildIntegrationToolSchemas('user-cache') expect(mockGetHighestPrioritySubscription).toHaveBeenCalledTimes(1) expect(mockCreateUserToolSchema).toHaveBeenCalledTimes(3) expect(second[0].input_schema).not.toHaveProperty('mutated') - expect(second[0].outputs).not.toHaveProperty('mutated') }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index b4f56f4b014..9bea8cddc10 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -9,12 +9,12 @@ import { filterExposedIntegrationTools, getExposedIntegrationTools, } from '@/lib/copilot/integration-tools' -import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { stripVersionSuffix } from '@/tools/utils' @@ -33,8 +33,6 @@ interface BuildPayloadParams { model: string provider?: string contexts?: Array<{ type: string; content: string; tag?: string; path?: string }> - /** MCP servers explicitly tagged on this turn. Untagged servers stay unavailable. */ - mcpServerIds?: string[] fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }> commands?: string[] chatId?: string @@ -51,26 +49,18 @@ interface BuildPayloadParams { email?: string timezone?: string } + includeMothershipTools?: boolean } export interface ToolSchema { name: string description: string input_schema: Record - outputs?: Record defer_loading?: boolean executeLocally?: boolean params?: Record /** Canonical integration service/folder (e.g. "slack"), for server-side grouping. */ service?: string - /** - * Operation stem within the service — the VFS doc filename without `.json` - * (e.g. "list_users" for id "slack_list_users"). Stamped so the server can - * hand agents the exact `components/integrations/{service}/{operation}.json` - * path instead of making them derive it from the id (deriving is how the id - * gets guessed as the filename). - */ - operation?: string oauth?: { required: boolean; provider: string } } @@ -105,7 +95,6 @@ function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] { input_schema: { ...tool.input_schema }, } if (tool.params) cloned.params = { ...tool.params } - if (tool.outputs) cloned.outputs = structuredClone(tool.outputs) if (tool.oauth) cloned.oauth = { ...tool.oauth } return cloned }) @@ -216,7 +205,7 @@ async function buildIntegrationToolSchemasUncached( } const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis) - for (const { toolId, config: toolConfig, service, operation } of exposedTools) { + for (const { toolId, config: toolConfig, service } of exposedTools) { try { if (allowedIntegrations && toolIdToBlockType) { const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId)) @@ -231,23 +220,12 @@ async function buildIntegrationToolSchemasUncached( integrationTools.push({ name: toolId, service, - operation, description: getCopilotToolDescription(toolConfig, { isHosted, fallbackName: toolId, appendEmailTagline: shouldAppendEmailTagline, }), input_schema: { ...userSchema }, - ...(toolConfig.outputs && { - outputs: Object.fromEntries( - Object.entries(toolConfig.outputs) - .filter(([, output]) => output != null) - .map(([key, output]) => [ - key, - { type: output.type, description: output.description }, - ]) - ), - }), defer_loading: true, executeLocally: catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client', @@ -327,8 +305,7 @@ export async function buildCopilotRequestPayload( f.key, filename, mediaType, - f.size, - userMessageId + f.size ) // Encode the read path per the percent-encoded VFS convention (matches // files/ and the uploads glob output). The materialize_file `fileName` @@ -366,26 +343,30 @@ export async function buildCopilotRequestPayload( const allContexts = [...(contexts ?? []), ...uploadContexts] let integrationTools: ToolSchema[] = [] - let mothershipTools: ToolSchema[] = [] + const mothershipTools: ToolSchema[] = [] const payloadLogger = logger.withMetadata({ messageId: userMessageId }) - // "superagent" is a legacy wire value for Direct Action mode; both modes - // execute connected-service operations through the main-agent gateway. - if (effectiveMode === 'build' || effectiveMode === 'superagent') { + if (effectiveMode === 'build') { integrationTools = await buildIntegrationToolSchemas( userId, userMessageId, { schemaSurface: 'copilot' }, params.workspaceId ) - } - if (params.workspaceId && params.mcpServerIds?.length) { - mothershipTools = await buildTaggedMcpToolSchemas( - userId, - params.workspaceId, - params.mcpServerIds - ) + if (params.includeMothershipTools && params.workspaceId) { + // Expose all workspace user-created skills via the single load_user_skill + // tool. Available to every user; content is fetched sim-side when the + // model calls it. + try { + const userSkillTool = await buildUserSkillTool(params.workspaceId) + if (userSkillTool) mothershipTools.push(userSkillTool) + } catch (error) { + logger.warn('Failed to build load_user_skill tool', { + error: toError(error).message, + }) + } + } } return { diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts index 2b9e5d52e9e..afe43cf6a08 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -90,10 +90,10 @@ describe('persisted-message', () => { const persisted = buildPersistedAssistantMessage(result) expect(persisted.content).not.toContain('sk-sim-secret-123') - expect(persisted.content).toContain('{"type":"sim_key"}') + expect(persisted.content).toContain('"redacted":true') const textBlock = persisted.contentBlocks?.find((b) => b.type === 'text') expect(textBlock?.content).not.toContain('sk-sim-secret-123') - expect(textBlock?.content).toContain('{"type":"sim_key"}') + expect(textBlock?.content).toContain('"redacted":true') }) it('redacts sim_key credential tags split across streamed text chunks', () => { @@ -119,7 +119,7 @@ describe('persisted-message', () => { expect(persisted.contentBlocks).toBeDefined() const joined = (persisted.contentBlocks ?? []).map((b) => b.content ?? '').join('') expect(joined).not.toContain('sk-sim-secret-12345') - expect(joined).toContain('{"type":"sim_key"}') + expect(joined).toContain('"redacted":true') }) it('redacts the api key from a persisted generate_api_key tool result output', () => { diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 41fe97ea31b..15a5ef1ef63 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -37,13 +37,6 @@ interface PersistedToolCall { export interface PersistedContentBlock { type: MothershipStreamV1EventType lane?: MothershipStreamV1StreamScope['lane'] - /** - * Subagent name on lane text blocks. The span-tree parser needs a name to - * create a group for content whose `subagent` start block is missing (resume - * legs re-emit text without re-emitting start); without it the prose is - * silently dropped on reload. - */ - agent?: string channel?: MothershipStreamV1TextChannel phase?: MothershipStreamV1ToolPhase kind?: MothershipStreamV1SpanPayloadKind @@ -75,9 +68,6 @@ interface PersistedMessageContext { fileId?: string folderId?: string chatId?: string - blockType?: string - skillId?: string - serverId?: string } export interface PersistedMessage { @@ -198,7 +188,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lane: 'subagent', channel: MothershipStreamV1TextChannel.assistant, content: block.content, - ...(block.subagent ? { agent: block.subagent } : {}), } case 'subagent_thinking': return { @@ -206,7 +195,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { lane: 'subagent', channel: MothershipStreamV1TextChannel.thinking, content: block.content, - ...(block.subagent ? { agent: block.subagent } : {}), } case 'tool_call': { if (!block.toolCall) { @@ -272,17 +260,7 @@ export function buildPersistedAssistantMessage( } if (result.contentBlocks.length > 0) { - // Reasoning is display-transient and never rendered, so it is never - // persisted either: storing it bloats whale chats and lets the persisted - // turn diverge from the streamed one (the refresh-vs-switch mismatch). - // This is the single write-side choke point for assistant blocks, so the - // guarantee holds for every terminal path (complete, cancelled, error). - const withoutThinking = result.contentBlocks.filter( - (block) => block.type !== 'thinking' && block.type !== 'subagent_thinking' - ) - if (withoutThinking.length > 0) { - message.contentBlocks = mergeAndRedactPersistedBlocks(withoutThinking.map(mapContentBlock)) - } + message.contentBlocks = mergeAndRedactPersistedBlocks(result.contentBlocks.map(mapContentBlock)) } return message @@ -356,9 +334,6 @@ export function buildPersistedUserMessage(params: UserMessageParams): PersistedM ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), })) } @@ -376,7 +351,6 @@ const CANONICAL_BLOCK_TYPES: Set = new Set(Object.values(MothershipStrea interface RawBlock { type: string lane?: string - agent?: string content?: string /** Go persists text blocks with key "text" instead of "content" */ text?: string @@ -442,7 +416,6 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { if (block.lane === 'subagent') { result.lane = block.lane } - if (block.agent) result.agent = block.agent const blockContent = block.content ?? block.text if (blockContent !== undefined) result.content = blockContent if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel @@ -676,9 +649,6 @@ export function normalizeMessage(raw: Record): PersistedMessage ...(c.fileId ? { fileId: c.fileId } : {}), ...(c.folderId ? { folderId: c.folderId } : {}), ...(c.chatId ? { chatId: c.chatId } : {}), - ...(c.blockType ? { blockType: c.blockType } : {}), - ...(c.skillId ? { skillId: c.skillId } : {}), - ...(c.serverId ? { serverId: c.serverId } : {}), })) } diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index 92eea63c09f..465006952d2 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -295,26 +295,6 @@ describe('handleUnifiedChatPost', () => { ) }) - it('forwards slash-selected MCP server ids to the request-local tool builder', async () => { - const response = await handleUnifiedChatPost( - new NextRequest('http://localhost/api/copilot/chat', { - method: 'POST', - body: JSON.stringify({ - message: '/Docs search auth', - workspaceId: 'ws-1', - createNewChat: true, - contexts: [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], - }), - }) - ) - - expect(response.status).toBe(200) - expect(buildCopilotRequestPayload).toHaveBeenCalledWith( - expect.objectContaining({ mcpServerIds: ['mcp-server-1'] }), - { selectedModel: '' } - ) - }) - it('persists cancelled partial responses from the server lifecycle', async () => { await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 73b30948c4f..bd86a60c314 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -118,7 +118,6 @@ const ChatContextSchema = z.object({ 'scheduledtask', 'integration', 'skill', - 'mcp', ]), label: z.string(), chatId: z.string().optional(), @@ -132,7 +131,6 @@ const ChatContextSchema = z.object({ folderId: z.string().optional(), fileFolderId: z.string().optional(), skillId: z.string().optional(), - serverId: z.string().optional(), scheduleId: z.string().optional(), }) @@ -177,7 +175,6 @@ type UnifiedChatBranch = userMessageId: string chatId?: string contexts: Array<{ type: string; content: string; tag?: string; path?: string }> - mcpServerIds?: string[] fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string entitlements?: string[] @@ -216,7 +213,6 @@ type UnifiedChatBranch = userMessageId: string chatId?: string contexts: Array<{ type: string; content: string; tag?: string; path?: string }> - mcpServerIds?: string[] fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string entitlements?: string[] @@ -630,7 +626,6 @@ async function resolveBranch(params: { model: selectedModel, provider: payloadParams.provider, contexts: payloadParams.contexts, - mcpServerIds: payloadParams.mcpServerIds, fileAttachments: payloadParams.fileAttachments, commands: payloadParams.commands, chatId: payloadParams.chatId, @@ -690,7 +685,6 @@ async function resolveBranch(params: { mode: 'agent', model: '', contexts: payloadParams.contexts, - mcpServerIds: payloadParams.mcpServerIds, fileAttachments: payloadParams.fileAttachments, chatId: payloadParams.chatId, workspaceContext: payloadParams.workspaceContext, @@ -699,6 +693,7 @@ async function resolveBranch(params: { entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, userMetadata: payloadParams.userMetadata, + includeMothershipTools: true, }, { selectedModel: '' } ), @@ -1002,18 +997,14 @@ export async function handleUnifiedChatPost(req: NextRequest) { [TraceAttr.CopilotFileAttachmentsCount]: body.fileAttachments?.length ?? 0, [TraceAttr.CopilotContextsCount]: normalizedContexts.length, }, - () => { - const mcpServerIds = normalizedContexts.flatMap((context) => - context.kind === 'mcp' && context.serverId ? [context.serverId] : [] - ) - return branch.kind === 'workflow' + () => + branch.kind === 'workflow' ? branch.buildPayload({ message: body.message, userId: authenticatedUserId, userMessageId, chatId: actualChatId, contexts: agentContexts, - mcpServerIds, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, entitlements, @@ -1036,7 +1027,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMessageId, chatId: actualChatId, contexts: agentContexts, - mcpServerIds, fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, entitlements, @@ -1044,8 +1034,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { userMetadata, workspaceContext, vfs, - }) - }, + }), 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 970e1a9a531..e1d6ec21740 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -6,13 +6,9 @@ import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' -const { discoverServerTools, getSkillById } = vi.hoisted(() => ({ - discoverServerTools: vi.fn(), - getSkillById: vi.fn(), -})) +const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) -vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) /** * Overrides the global `@sim/db` mock: the logs-context tests below need * controllable row data, which the stable `dbChainMockFns.limit` provides. @@ -78,40 +74,6 @@ describe('processContextsServer - skill contexts', () => { }) }) -describe('processContextsServer - MCP contexts', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('lists only the tools from the slash-selected MCP server', async () => { - discoverServerTools.mockResolvedValue([ - { - serverId: 'mcp-server-1', - serverName: 'Docs', - name: 'search', - description: 'Search documentation', - inputSchema: { type: 'object', properties: {} }, - }, - ]) - - const result = await processContextsServer( - [{ kind: 'mcp', serverId: 'mcp-server-1', label: 'Docs' }], - 'user-1', - '/Docs find auth docs', - 'ws-1' - ) - - expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1') - expect(result).toEqual([ - expect.objectContaining({ - type: 'mcp', - tag: '/Docs', - content: expect.stringContaining('mcp-server-1-search'), - }), - ]) - }) -}) - describe('processContextsServer - logs 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 610e79aa8ed..a49ea07ccc0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -21,8 +21,6 @@ import { import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { toOverview } from '@/lib/logs/log-views' import type { TraceSpan } from '@/lib/logs/types' -import { mcpService } from '@/lib/mcp/service' -import { createMcpToolId } from '@/lib/mcp/utils' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -83,24 +81,6 @@ export async function processContextsServer( ctx.label ? `@${ctx.label}` : '@' ) } - if (ctx.kind === 'mcp' && ctx.serverId && currentWorkspaceId) { - const tools = await mcpService.discoverServerTools(userId, ctx.serverId, currentWorkspaceId) - if (tools.length === 0) return null - const toolLines = tools.map((tool) => { - const name = createMcpToolId(tool.serverId, tool.name) - return `- ${name}: ${tool.description || tool.name}` - }) - return { - type: 'mcp', - tag: ctx.label ? `/${ctx.label}` : '/', - content: [ - `The user explicitly enabled the MCP server "${ctx.label || ctx.serverId}" for this turn.`, - 'Its request-scoped tools are listed below. Load a tool with load_custom_tool({ type: "mcp", name: "" }) before calling it.', - 'Do not narrate discovery, loading, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', - ...toolLines, - ].join('\n'), - } - } if (ctx.kind === 'past_chat' && ctx.chatId) { return await processPastChatFromDb( ctx.chatId, diff --git a/apps/sim/lib/copilot/chat/rewrite-file-references.ts b/apps/sim/lib/copilot/chat/rewrite-file-references.ts deleted file mode 100644 index 021c6c19a2c..00000000000 --- a/apps/sim/lib/copilot/chat/rewrite-file-references.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import type { MothershipResource } from '@/lib/copilot/resources/types' -import { rewriteForkContentRefs } from '@/ee/workspace-forking/lib/remap/remap-content-refs' - -/** - * Old->new translation tables produced while copying a chat's files - * (`planChatFileCopies`): row ids (view-URLs, attachment ids, resource ids, - * context chips) and storage keys (serve-URLs, attachment keys). - */ -export interface ChatFileRefMaps { - fileIds: ReadonlyMap - fileKeys: ReadonlyMap -} - -function hasMappings(maps: ChatFileRefMaps): boolean { - return maps.fileIds.size > 0 || maps.fileKeys.size > 0 -} - -function rewriteText(text: string, maps: ChatFileRefMaps): string { - return rewriteForkContentRefs(text, { fileIds: maps.fileIds, fileKeys: maps.fileKeys }) -} - -/** - * Re-point every file reference in a copied transcript at the copied files, so - * the fork is self-contained (it survives the original chat's deletion). - * Rewrites: free-text URLs in `content` and text content blocks (serve/view/ - * in-app/`sim:file` forms, via the shared fork grammar), attachment chip - * ids+keys, and `@`-mention context chip file ids. References to anything not - * in the maps (shared workspace files, workflows, other chats) pass through - * unchanged. Pure; returns the input array untouched when there is nothing to - * rewrite. - * - * Pass-through cannot leave the fork pointing at uncopied CHAT-OWNED files: - * a message can only reference files that existed when it was written, every - * chat-owned file is stamped with the user message of the turn it was born in - * (NULL-stamped legacy rows are copied into every fork), and the fork copies - * every chat-owned file born at-or-before the cut — so any chat-owned file a - * kept message references is always in the maps. The only reachable leftovers - * are files soft-deleted before the fork, whose links are equally dead in the - * source chat. - */ -export function rewriteMessageFileRefs( - messages: PersistedMessage[], - maps: ChatFileRefMaps -): PersistedMessage[] { - if (!hasMappings(maps)) return messages - return messages.map((message) => { - const rewritten: PersistedMessage = { - ...message, - content: rewriteText(message.content, maps), - } - if (message.contentBlocks?.length) { - rewritten.contentBlocks = message.contentBlocks.map((block) => - block.content ? { ...block, content: rewriteText(block.content, maps) } : block - ) - } - if (message.fileAttachments?.length) { - rewritten.fileAttachments = message.fileAttachments.map((att) => ({ - ...att, - id: maps.fileIds.get(att.id) ?? att.id, - key: maps.fileKeys.get(att.key) ?? att.key, - })) - } - if (message.contexts?.length) { - rewritten.contexts = message.contexts.map((ctx) => - ctx.fileId ? { ...ctx, fileId: maps.fileIds.get(ctx.fileId) ?? ctx.fileId } : ctx - ) - } - return rewritten - }) -} - -/** - * Re-point `file`-typed resource entries (the chat's attached-resources list - * stores raw `workspace_files.id`s) at the copied files. Non-file resources - * (workflows, tables, knowledge bases…) reference shared workspace entities - * and pass through unchanged. - * - * `dropFileIds` is the source chat's chat-owned file ids (no timeline cut). - * A file resource pointing at one of these that was NOT copied is a ghost in - * the new chat — its file stays behind (an upload born after the cut) — so it - * is dropped rather than left pointing at the source chat's file. Shared - * workspace files are not chat-owned, never appear in the set, and pass - * through unchanged. - */ -export function rewriteResourceFileRefs( - resources: MothershipResource[], - maps: ChatFileRefMaps, - dropFileIds?: ReadonlySet -): MothershipResource[] { - if (!hasMappings(maps) && !dropFileIds?.size) return resources - return resources.flatMap((resource) => { - if (resource.type !== 'file') return [resource] - const copyId = maps.fileIds.get(resource.id) - if (copyId) return [{ ...resource, id: copyId }] - if (dropFileIds?.has(resource.id)) return [] - return [resource] - }) -} diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts index 0d2e28b8537..70fe637e6d2 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts @@ -7,21 +7,12 @@ import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' import { captureRevealedSimKeys, extractRevealedSimKeys, - extractRevealedSimKeysFromBlocks, restoreRevealedSimKeysForMessage, - toolResultForModel, } from './sim-key-redaction' const credential = (value: string) => `${JSON.stringify({ value, type: 'sim_key' })}` const redacted = `${JSON.stringify({ type: 'sim_key', redacted: true })}` -// The value-less placeholder the model now emits (no `redacted` flag). -const placeholder = `${JSON.stringify({ type: 'sim_key' })}` - -const apiKeyBlock = (key: string) => ({ - type: 'tool_call' as const, - toolCall: { name: 'generate_api_key', result: { success: true, output: { id: 'k1', key } } }, -}) describe('sim-key-redaction', () => { describe('extractRevealedSimKeys', () => { @@ -37,55 +28,6 @@ describe('sim-key-redaction', () => { }) }) - describe('toolResultForModel', () => { - it('reduces a successful generate_api_key result to only its status message', () => { - const data = { - id: 'k1', - name: 'prod', - key: 'sk-sim-secret', - workspaceId: 'ws-1', - message: 'API key "prod" created.', - } - expect(toolResultForModel('generate_api_key', data)).toBe('API key "prod" created.') - }) - - it('leaves other tools untouched', () => { - const data = { key: 'not-a-secret', ok: true } - expect(toolResultForModel('read', data)).toBe(data) - }) - - it('passes generate_api_key errors through (no key to withhold)', () => { - const data = { error: 'name is required' } - expect(toolResultForModel('generate_api_key', data)).toBe(data) - expect(toolResultForModel('generate_api_key', undefined)).toBe(undefined) - }) - }) - - describe('extractRevealedSimKeysFromBlocks', () => { - it('pulls generate_api_key output keys in block order', () => { - expect( - extractRevealedSimKeysFromBlocks([apiKeyBlock('sk-sim-A'), apiKeyBlock('sk-sim-B')]) - ).toEqual(['sk-sim-A', 'sk-sim-B']) - }) - - it('skips redacted markers and unrelated tools', () => { - const blocks = [ - apiKeyBlock('[REDACTED]'), - { - type: 'tool_call' as const, - toolCall: { name: 'read', result: { success: true, output: { key: 'sk-x' } } }, - }, - apiKeyBlock('sk-sim-A'), - ] - expect(extractRevealedSimKeysFromBlocks(blocks)).toEqual(['sk-sim-A']) - }) - - it('returns nothing for empty/undefined block lists', () => { - expect(extractRevealedSimKeysFromBlocks(undefined)).toEqual([]) - expect(extractRevealedSimKeysFromBlocks([])).toEqual([]) - }) - }) - describe('captureRevealedSimKeys', () => { it('records new keys under each provided key', () => { const cache = new Map() @@ -117,21 +59,6 @@ describe('sim-key-redaction', () => { captureRevealedSimKeys(cache, ['msg-1'], 'plain assistant text') expect(cache.has('msg-1')).toBe(false) }) - - it('sources the key from the generate_api_key tool result (model text is a redacted placeholder)', () => { - const cache = new Map() - captureRevealedSimKeys(cache, ['msg-1', 'req-1'], `Here is your key: ${redacted}`, [ - apiKeyBlock('sk-sim-fromtool'), - ]) - expect(cache.get('msg-1')).toEqual(['sk-sim-fromtool']) - expect(cache.get('req-1')).toEqual(['sk-sim-fromtool']) - }) - - it('prefers tool-result keys over any inline content values', () => { - const cache = new Map() - captureRevealedSimKeys(cache, ['msg-1'], credential('sk-content'), [apiKeyBlock('sk-tool')]) - expect(cache.get('msg-1')).toEqual(['sk-tool']) - }) }) describe('restoreRevealedSimKeysForMessage', () => { @@ -149,32 +76,6 @@ describe('sim-key-redaction', () => { expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') }) - it('fills a value-less {"type":"sim_key"} placeholder (no redacted flag needed)', () => { - const cache = new Map([['msg-1', ['sk-sim-A']]]) - const msg: ChatMessage = { - id: 'msg-1', - role: 'assistant', - content: `Here is your key: ${placeholder} save it.`, - contentBlocks: [{ type: 'text', content: `Here is your key: ${placeholder} save it.` }], - } - const restored = restoreRevealedSimKeysForMessage(msg, cache) - expect(restored.content).toContain('"sk-sim-A"') - expect(restored.contentBlocks?.[0].content).toContain('"sk-sim-A"') - }) - - it('fills value-less and redacted placeholders positionally in one message', () => { - const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) - const msg: ChatMessage = { - id: 'msg-1', - role: 'assistant', - content: `first ${placeholder} second ${redacted}`, - } - const restored = restoreRevealedSimKeysForMessage(msg, cache) - expect(restored.content).toBe( - `first ${credential('sk-sim-A')} second ${credential('sk-sim-B')}` - ) - }) - it('substitutes multiple keys in stream order', () => { const cache = new Map([['msg-1', ['sk-sim-A', 'sk-sim-B']]]) const msg: ChatMessage = { diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.ts b/apps/sim/lib/copilot/chat/sim-key-redaction.ts index 023efd6baee..d5aba0f302d 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.ts +++ b/apps/sim/lib/copilot/chat/sim-key-redaction.ts @@ -1,4 +1,3 @@ -import { isRecordLike } from '@sim/utils/object' import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message' import { MothershipStreamV1EventType, @@ -24,15 +23,17 @@ import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/ho */ const CREDENTIAL_TAG_PATTERN = /([\s\S]*?)<\/credential>/g +const REDACTED_TAG_PATTERN = /[^<]*"redacted"\s*:\s*true[^<]*<\/credential>/ const SIM_KEY_TYPE = 'sim_key' -// The persisted / secret-stripped form of a sim_key tag: value-less, which is -// exactly how the UI renders the masked state. No `redacted` flag needed — a -// sim_key chip is masked iff it has no value. -const VALUELESS_SIM_KEY_TAG = `${JSON.stringify({ type: SIM_KEY_TYPE })}` +const REDACTED_SIM_KEY_TAG = `${JSON.stringify({ + type: SIM_KEY_TYPE, + redacted: true, +})}` interface CredentialTagBody { type?: unknown value?: unknown + redacted?: unknown } function parseCredentialBody(body: string): CredentialTagBody | null { @@ -43,34 +44,22 @@ function parseCredentialBody(body: string): CredentialTagBody | null { } } -/** - * True when `content` holds a `sim_key` credential tag that still needs its - * value filled in — i.e. any value-less `sim_key` tag: the model's - * `{"type":"sim_key"}` placeholder, the persisted form, or a legacy - * `{"type":"sim_key","redacted":true}` tag. All are recognized by the absence - * of a `value`. - */ -function hasFillableSimKeyTag(content: string | undefined): boolean { - if (typeof content !== 'string' || !content.includes('')) return false - for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { - const parsed = parseCredentialBody(match[1]) - if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) return true - } - return false +function hasRedactedSimKeyTag(content: string | undefined): boolean { + return typeof content === 'string' && REDACTED_TAG_PATTERN.test(content) } // Write side --------------------------------------------------------------- /** - * Replace every `` tag in `content` with the - * value-less placeholder, so a revealed key is never persisted. Other credential - * types (e.g. OAuth `link`) and malformed bodies pass through unchanged. + * Replace every revealed `` tag in `content` with a + * placeholder marked `redacted: true`. Other credential types (e.g. OAuth + * `link`) and malformed bodies pass through unchanged. */ export function redactSensitiveContent(content: T): T { if (typeof content !== 'string' || !content.includes('')) return content return content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - return parsed?.type === SIM_KEY_TYPE ? VALUELESS_SIM_KEY_TAG : match + return parsed?.type === SIM_KEY_TYPE ? REDACTED_SIM_KEY_TAG : match }) as T } @@ -94,23 +83,6 @@ export function redactToolCallResult( } } -/** - * The model-facing result of `generate_api_key`. The generated key is a - * client-only artifact — it rides the SSE tool result to the browser and renders - * as the `sim_key` chip — so the model (and the persisted conversation) must - * never receive it. Rather than subtract the secret from the full payload, the - * model's result IS the status: on success it gets only the tool's message (no - * key, no id/name/workspaceId); a failure passes through so the model still sees - * the error. Every other tool's terminal data is returned unchanged. - */ -export function toolResultForModel(toolName: string | undefined, data: unknown): unknown { - if (toolName !== GenerateApiKey.id) return data - if (!isRecordLike(data)) return data - const record = data - if (typeof record.key !== 'string') return data - return record.message -} - function isMergeableAssistantTextBlock(block: PersistedContentBlock): boolean { return ( block.type === MothershipStreamV1EventType.text && @@ -135,12 +107,6 @@ export function mergeAndRedactPersistedBlocks( const out: PersistedContentBlock[] = [] let runStart = -1 let runLane: PersistedContentBlock['lane'] - // A run must stay within ONE lane instance: two parallel subagents both have - // lane 'subagent', and merging across them would append span B's prose into - // span A's block (B's spanId is lost with it). Key the run on span identity, - // not just the lane flag. - let runSpanId: PersistedContentBlock['spanId'] - let runParentToolCallId: PersistedContentBlock['parentToolCallId'] const flushRun = (endExclusive: number) => { if (runStart < 0) return @@ -163,19 +129,12 @@ export function mergeAndRedactPersistedBlocks( for (let i = 0; i < blocks.length; i++) { const block = blocks[i] - const sameRun = - runStart >= 0 && - isMergeableAssistantTextBlock(block) && - runLane === block.lane && - runSpanId === block.spanId && - runParentToolCallId === block.parentToolCallId + const sameRun = runStart >= 0 && isMergeableAssistantTextBlock(block) && runLane === block.lane if (sameRun) continue flushRun(i) if (isMergeableAssistantTextBlock(block)) { runStart = i runLane = block.lane - runSpanId = block.spanId - runParentToolCallId = block.parentToolCallId } else { out.push(block) } @@ -197,76 +156,34 @@ export type RevealedSimKeysByMessage = Map /** * Scan an assembled assistant message for `` tags - * and return their values in stream order; value-less (masked/placeholder) tags - * carry no string value and are skipped. + * and return their values in stream order, skipping anything already redacted. */ export function extractRevealedSimKeys(content: string): string[] { if (!content || !content.includes('')) return [] const values: string[] = [] for (const match of content.matchAll(CREDENTIAL_TAG_PATTERN)) { const parsed = parseCredentialBody(match[1]) - if (parsed?.type === SIM_KEY_TYPE && typeof parsed.value === 'string') { + if (parsed?.type === SIM_KEY_TYPE && !parsed.redacted && typeof parsed.value === 'string') { values.push(parsed.value) } } return values } -/** Minimal shape of a rendered/streamed block carrying a tool result. */ -interface ToolResultBlockLike { - toolCall?: { name?: string; result?: unknown } | null -} - -/** - * Pull the freshly-generated key(s) out of `generate_api_key` tool results in - * block order. This is the authoritative source for the `sim_key` chip now that - * the model never sees (or emits) the value — it only emits a redacted - * placeholder, and the real value rides in the tool result on the live stream. - * `[REDACTED]` outputs (post-persist/refetch) are skipped so a reloaded - * transcript doesn't cache the masked marker over a live value. - */ -export function extractRevealedSimKeysFromBlocks( - blocks: ReadonlyArray | undefined -): string[] { - if (!blocks?.length) return [] - const values: string[] = [] - for (const block of blocks) { - const toolCall = block.toolCall - if (!toolCall || toolCall.name !== GenerateApiKey.id) continue - const result = toolCall.result - if (!isRecordLike(result)) continue - const output = result.output - if (!isRecordLike(output)) continue - const key = output.key - if (typeof key === 'string' && key.length > 0 && key !== REDACTED_MARKER) { - values.push(key) - } - } - return values -} - /** * Extend the cache entries for the given keys with any newly-revealed values. * Each key in `keys` is written the same array — passing both the live-stream * id and the persisted `requestId` lets the post-finalize refetch hit the * cache after the message is renamed to its real UUID. The longest captured * list wins so a rerun that surfaces fewer values can't shrink the entry. - * - * Values are sourced from the `generate_api_key` tool results (`blocks`) first — - * that is where the key now lives, since the model only emits a redacted - * placeholder tag — falling back to any inline `sim_key` tag values in - * `content` for backward compatibility with pre-change transcripts. */ export function captureRevealedSimKeys( cache: RevealedSimKeysByMessage, keys: ReadonlyArray, - content: string, - blocks?: ReadonlyArray + content: string ): void { - const fromBlocks = extractRevealedSimKeysFromBlocks(blocks) - // extractRevealedSimKeys already returns [] when `content` has no tag, so no - // separate includes() guard is needed. - const next = fromBlocks.length > 0 ? fromBlocks : extractRevealedSimKeys(content) + if (!content.includes('')) return + const next = extractRevealedSimKeys(content) if (next.length === 0) return for (const key of keys) { if (!key) continue @@ -291,10 +208,7 @@ function restoreInString( let changed = false const next = content.replace(CREDENTIAL_TAG_PATTERN, (match, body: string) => { const parsed = parseCredentialBody(body) - // Any value-less sim_key tag is a fill slot — the model's placeholder, the - // persisted form, or a legacy `{"redacted":true}` tag. Already-filled tags - // carry a `value` and are left untouched (idempotent). - if (parsed?.type === SIM_KEY_TYPE && parsed.value === undefined) { + if (parsed?.type === SIM_KEY_TYPE && parsed.redacted === true) { const value = revealedValues[cursor] cursor += 1 if (typeof value === 'string') { @@ -321,8 +235,8 @@ export function restoreRevealedSimKeysForMessage( cache.get(message.id) ?? (message.requestId ? cache.get(message.requestId) : undefined) if (!revealed || revealed.length === 0) return message if ( - !hasFillableSimKeyTag(message.content) && - !message.contentBlocks?.some((b) => hasFillableSimKeyTag(b.content)) + !hasRedactedSimKeyTag(message.content) && + !message.contentBlocks?.some((b) => hasRedactedSimKeyTag(b.content)) ) { return message } @@ -331,7 +245,7 @@ export function restoreRevealedSimKeysForMessage( let blocksChanged = false let blockCursor = 0 const nextBlocks: ContentBlock[] | undefined = message.contentBlocks?.map((block) => { - if (!hasFillableSimKeyTag(block.content)) return block + if (!hasRedactedSimKeyTag(block.content)) return block const restored = restoreInString(block.content as string, revealed, blockCursor) blockCursor = restored.cursor if (!restored.changed) return block diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts index 44e36084b0d..2f8caa114f7 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -74,21 +74,6 @@ describe('buildWorkspaceMd - workflow VFS state paths', () => { expect(md).toContain('VFS dir: `workflows/Root%20Flow`') expect(md).toContain('VFS state path: `workflows/Root%20Flow/state.json`') }) - - it('never exposes workflow descriptions in markdown or the typed snapshot', () => { - const workflowWithPrivateDescription = { - id: 'wf-1', - name: 'Private Flow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - isDeployed: false, - folderPath: null, - } - const data = baseData({ workflows: [workflowWithPrivateDescription] }) - - expect(buildWorkspaceMd(data)).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - expect(JSON.stringify(buildVfsSnapshot(data))).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - expect(buildVfsSnapshot(data).workflows?.[0]).not.toHaveProperty('description') - }) }) describe('buildWorkspaceMd - connected integrations / credentials', () => { @@ -130,16 +115,6 @@ describe('buildWorkspaceMd - connected integrations / credentials', () => { const md = buildWorkspaceMd(baseData({ oauthIntegrations: [] })) expect(md).toContain('## Connected Integrations\n(none)') }) - - it('injects available environment credential names into markdown and the typed snapshot', () => { - const data = baseData({ envVariables: ['OPENAI_API_KEY', 'STRIPE_SECRET_KEY'] }) - - const md = buildWorkspaceMd(data) - expect(md).toContain('## Environment Variables (2)') - expect(md).toContain('- OPENAI_API_KEY') - expect(md).toContain('- STRIPE_SECRET_KEY') - expect(buildVfsSnapshot(data).envVars).toEqual(['OPENAI_API_KEY', 'STRIPE_SECRET_KEY']) - }) }) describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 11ee6ac3702..7558b06f56c 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -19,10 +19,7 @@ import type { } from '@/lib/copilot/generated/vfs-snapshot-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { - getAccessibleEnvCredentials, - getAccessibleOAuthCredentials, -} from '@/lib/credentials/environment' +import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -55,6 +52,7 @@ export interface WorkspaceMdData { workflows: Array<{ id: string name: string + description?: string | null isDeployed: boolean lastRunAt?: Date | null folderPath?: string | null @@ -157,6 +155,7 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }) parts.push(`${indent} VFS dir: \`${workflowDir}\``) parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``) + if (wf.description) parts.push(`${indent} ${wf.description}`) // `deployed` is a structural flag (kept); `lastRunAt` is intentionally // omitted — it changes on every run and would bust the cached prompt // prefix that carries this inventory. Current run data lives in @@ -292,8 +291,8 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { .sort(byNameThenId) .map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) sections.push( - `## Agent Block Skills — NOT FOR YOU (${data.skills.length})\n` + - 'These are user-created skills used by agent blocks in the workspace and are NOT instructions for you\n' + + `## Skills (${data.skills.length})\n` + + 'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' + lines.join('\n') ) } @@ -351,7 +350,6 @@ async function buildWorkspaceMdData( tables, files, credentials, - envCredentials, customTools, mcpServerRows, skillRows, @@ -364,6 +362,7 @@ async function buildWorkspaceMdData( .select({ id: workflow.id, name: workflow.name, + description: workflow.description, isDeployed: workflow.isDeployed, lastRunAt: workflow.lastRunAt, folderId: workflow.folderId, @@ -407,8 +406,6 @@ async function buildWorkspaceMdData( getAccessibleOAuthCredentials(workspaceId, userId), - getAccessibleEnvCredentials(workspaceId, userId), - listCustomTools({ userId, workspaceId }), db @@ -514,13 +511,7 @@ async function buildWorkspaceMdData( displayName: c.displayName, role: c.role, })), - // Names only: make newly saved personal/workspace secrets visible to the - // next Mothership turn without ever putting their values on the wire. - // De-duplicate conflicts (the same key may exist in both scopes) and sort - // for byte-stable prompt snapshots. - envVariables: [...new Set(envCredentials.map((credential) => credential.envKey))].sort( - stableCompare - ), + envVariables: [], customTools: customTools.map((t) => ({ id: t.id, name: t.title })), customBlocks: customBlockSummaries, mcpServers: mcpServerRows, @@ -586,6 +577,7 @@ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { id: wf.id, name: wf.name, path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }), + ...(wf.description ? { description: wf.description } : {}), ...(wf.isDeployed ? { isDeployed: true } : {}), ...(wf.folderPath ? { folderPath: wf.folderPath } : {}), })) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index db0ca729e07..cce8011cd3e 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,12 +9,11 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' - | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' - | 'cp' | 'crawl_website' | 'create_file' + | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -51,6 +50,7 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' + | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -64,40 +64,42 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'mkdir' - | 'mv' + | 'move_file' + | 'move_file_folder' + | 'move_workflow' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' - | 'query_user_table' | 'read' | 'redeploy' + | 'rename_file' + | 'rename_file_folder' + | 'rename_workflow' + | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' - | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' - | 'search' | 'search_documentation' - | 'search_integration_tools' - | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' + | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' + | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' @@ -106,12 +108,11 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' - | 'call_integration_tool' | 'check_deployment_status' | 'complete_scheduled_task' - | 'cp' | 'crawl_website' | 'create_file' + | 'create_file_folder' | 'create_workflow' | 'create_workspace_mcp_server' | 'delete_file' @@ -148,6 +149,7 @@ export interface ToolCatalogEntry { | 'grep' | 'knowledge' | 'knowledge_base' + | 'list_file_folders' | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' @@ -161,45 +163,47 @@ export interface ToolCatalogEntry { | 'manage_skill' | 'materialize_file' | 'media' - | 'mkdir' - | 'mv' + | 'move_file' + | 'move_file_folder' + | 'move_workflow' | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' | 'promote_to_live' | 'query_logs' - | 'query_user_table' | 'read' | 'redeploy' + | 'rename_file' + | 'rename_file_folder' + | 'rename_workflow' + | 'research' | 'respond' | 'restore_resource' | 'run' | 'run_block' - | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' | 'scheduled_task' | 'scrape_page' - | 'search' | 'search_documentation' - | 'search_integration_tools' - | 'search_knowledge_base' | 'search_library_docs' | 'search_online' | 'search_patterns' | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' + | 'superagent' | 'table' | 'update_deployment_version' | 'update_scheduled_task_history' | 'update_workspace_mcp_server' + | 'user_memory' | 'user_table' | 'workflow' | 'workspace_file' parameters: unknown - requiredPermission?: 'admin' | 'write' + requiredPermission?: 'admin' | 'read' | 'write' resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: @@ -209,9 +213,10 @@ export interface ToolCatalogEntry { | 'file' | 'knowledge' | 'media' + | 'research' | 'run' | 'scheduled_task' - | 'search' + | 'superagent' | 'table' | 'workflow' } @@ -249,35 +254,6 @@ export const Auth: ToolCatalogEntry = { internal: true, } -export const CallIntegrationTool: ToolCatalogEntry = { - id: 'call_integration_tool', - name: 'call_integration_tool', - route: 'go', - mode: 'sync', - parameters: { - properties: { - arguments: { - additionalProperties: true, - description: "Inputs matching the selected operation's server-owned inputSchema.", - type: 'object', - }, - credentialId: { - description: - 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', - type: 'string', - }, - description: { - description: - 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', - type: 'string', - }, - toolId: { description: 'Exact toolId returned by search_integration_tools.', type: 'string' }, - }, - required: ['toolId', 'description', 'arguments'], - type: 'object', - }, -} - export const CheckDeploymentStatus: ToolCatalogEntry = { id: 'check_deployment_status', name: 'check_deployment_status', @@ -308,36 +284,6 @@ export const CompleteScheduledTask: ToolCatalogEntry = { }, } -export const Cp: ToolCatalogEntry = { - id: 'cp', - name: 'cp', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - destination: { - type: 'string', - 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', - description: - 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { type: 'string' }, - }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', - }, - }, - required: ['sources', 'destination', 'toolTitle'], - }, - requiredPermission: 'write', -} - export const CrawlWebsite: ToolCatalogEntry = { id: 'crawl_website', name: 'crawl_website', @@ -389,7 +335,7 @@ export const CreateFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', + 'Files to create or overwrite. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -431,6 +377,29 @@ export const CreateFile: ToolCatalogEntry = { capabilities: ['file_output'], } +export const CreateFileFolder: ToolCatalogEntry = { + id: 'create_file_folder', + name: 'create_file_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', + }, + workspaceId: { + type: 'string', + description: 'Optional workspace ID. Defaults to the current workspace.', + }, + }, + required: ['path'], + }, + requiredPermission: 'write', +} + export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -439,6 +408,7 @@ export const CreateWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { + description: { type: 'string', description: 'Optional workflow description.' }, folderId: { type: 'string', description: 'Optional folder ID.' }, name: { type: 'string', description: 'Workflow name.' }, workspaceId: { type: 'string', description: 'Optional workspace ID.' }, @@ -843,7 +813,7 @@ export const DeployCustomBlock: ToolCatalogEntry = { iconUrl: { type: 'string', description: - 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an https image URL. Omit to use the organization\'s default icon', + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', }, inputs: { type: 'array', @@ -1056,7 +1026,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { files: { type: 'array', description: - 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', + 'Files to create or overwrite. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1331,8 +1301,7 @@ export const Ffmpeg: ToolCatalogEntry = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1496,8 +1465,7 @@ export const FunctionExecute: ToolCatalogEntry = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1530,12 +1498,6 @@ export const FunctionExecute: ToolCatalogEntry = { }, }, }, - timeout: { - type: 'number', - description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', - default: 10, - }, title: { type: 'string', description: @@ -1668,8 +1630,7 @@ export const GenerateAudio: ToolCatalogEntry = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1803,8 +1764,7 @@ export const GenerateImage: ToolCatalogEntry = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1962,8 +1922,7 @@ export const GenerateVideo: ToolCatalogEntry = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -2452,6 +2411,23 @@ export const KnowledgeBase: ToolCatalogEntry = { }, } +export const ListFileFolders: ToolCatalogEntry = { + id: 'list_file_folders', + name: 'list_file_folders', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + workspaceId: { + type: 'string', + description: 'Optional workspace ID. Defaults to the current workspace.', + }, + }, + }, + requiredPermission: 'read', +} + export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', @@ -2640,16 +2616,35 @@ export const ManageFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { + destinationPath: { + type: 'string', + description: + 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', + }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, - operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, + name: { + type: 'string', + description: + 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', + }, + operation: { + type: 'string', + description: 'The operation to perform.', + enum: ['create', 'rename', 'move', 'delete'], + }, + parentId: { + type: 'string', + description: + 'Destination parent folder ID, used as a fallback when destinationPath is not given.', + }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', }, }, required: ['operation'], @@ -2866,57 +2861,72 @@ export const Media: ToolCatalogEntry = { internal: true, } -export const Mkdir: ToolCatalogEntry = { - id: 'mkdir', - name: 'mkdir', +export const MoveFile: ToolCatalogEntry = { + id: 'move_file', + name: 'move_file', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, paths: { type: 'array', - description: - 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string' }, }, - toolTitle: { + }, + required: ['paths'], + }, + requiredPermission: 'write', +} + +export const MoveFileFolder: ToolCatalogEntry = { + id: 'move_file_folder', + name: 'move_file_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + destinationPath: { type: 'string', description: - 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', + }, + path: { + type: 'string', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['paths', 'toolTitle'], + required: ['path'], }, requiredPermission: 'write', } -export const Mv: ToolCatalogEntry = { - id: 'mv', - name: 'mv', +export const MoveWorkflow: ToolCatalogEntry = { + id: 'move_workflow', + name: 'move_workflow', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - destination: { + folderId: { type: 'string', - 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).', + description: 'Target folder ID. Omit or pass empty string to move to workspace root.', }, - sources: { + workflowIds: { type: 'array', - 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.', + description: 'The workflow IDs to move.', items: { type: 'string' }, }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', - }, }, - required: ['sources', 'destination', 'toolTitle'], + required: ['workflowIds'], }, requiredPermission: 'write', } @@ -2929,11 +2939,6 @@ export const OauthGetAuthLink: ToolCatalogEntry = { parameters: { type: 'object', properties: { - credentialId: { - type: 'string', - description: - 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', - }, providerName: { type: 'string', description: @@ -3125,55 +3130,6 @@ export const QueryLogs: ToolCatalogEntry = { }, } -export const QueryUserTable: ToolCatalogEntry = { - id: 'query_user_table', - name: 'query_user_table', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', - }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, - tableId: { type: 'string', description: 'Table ID (required for all operations)' }, - }, - }, - operation: { - type: 'string', - description: 'The read operation to perform', - enum: ['get', 'get_schema', 'get_row', 'query_rows'], - }, - }, - 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 Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -3274,6 +3230,87 @@ export const Redeploy: ToolCatalogEntry = { requiredPermission: 'admin', } +export const RenameFile: ToolCatalogEntry = { + id: 'rename_file', + name: 'rename_file', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + newName: { + type: 'string', + description: + 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', + }, + path: { + type: 'string', + description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, + }, + required: ['path', 'newName'], + }, + resultSchema: { + type: 'object', + properties: { + data: { type: 'object', description: 'Contains id and the new name.' }, + message: { type: 'string', description: 'Human-readable outcome.' }, + success: { type: 'boolean', description: 'Whether the rename succeeded.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + +export const RenameFileFolder: ToolCatalogEntry = { + id: 'rename_file_folder', + name: 'rename_file_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'New folder name.' }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, + }, + required: ['path', 'name'], + }, + requiredPermission: 'write', +} + +export const RenameWorkflow: ToolCatalogEntry = { + id: 'rename_workflow', + name: 'rename_workflow', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'The new name for the workflow.' }, + workflowId: { type: 'string', description: 'The workflow ID to rename.' }, + }, + required: ['workflowId', 'name'], + }, + requiredPermission: 'write', +} + +export const Research: ToolCatalogEntry = { + id: 'research', + name: 'research', + route: 'subagent', + mode: 'async', + parameters: { + properties: { topic: { description: 'The topic to research.', type: 'string' } }, + required: ['topic'], + type: 'object', + }, + subagentId: 'research', + internal: true, +} + export const Respond: ToolCatalogEntry = { id: 'respond', name: 'respond', @@ -3287,12 +3324,6 @@ export const Respond: ToolCatalogEntry = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, - paths: { - description: - 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', - items: { type: 'string' }, - type: 'array', - }, success: { description: 'Whether the task completed successfully', type: 'boolean' }, type: { description: 'Optional logical result type override', type: 'string' }, }, @@ -3377,99 +3408,6 @@ export const RunBlock: ToolCatalogEntry = { clientExecutable: true, } -export const RunCode: ToolCatalogEntry = { - id: 'run_code', - name: 'run_code', - 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', - }, - 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'], - }, - requiredPermission: 'write', - capabilities: ['file_input', 'directory_input', 'table_input'], -} - export const RunFromBlock: ToolCatalogEntry = { id: 'run_from_block', name: 'run_from_block', @@ -3633,26 +3571,6 @@ export const ScrapePage: ToolCatalogEntry = { }, } -export const Search: ToolCatalogEntry = { - id: 'search', - name: 'search', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - task: { - description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - subagentId: 'search', - internal: true, -} - export const SearchDocumentation: ToolCatalogEntry = { id: 'search_documentation', name: 'search_documentation', @@ -3668,77 +3586,6 @@ export const SearchDocumentation: ToolCatalogEntry = { }, } -export const SearchIntegrationTools: ToolCatalogEntry = { - id: 'search_integration_tools', - name: 'search_integration_tools', - route: 'go', - mode: 'sync', - parameters: { - properties: { - limit: { - description: 'Maximum matches to return. Defaults to 5.', - maximum: 10, - minimum: 1, - type: 'integer', - }, - query: { - description: 'What the service operation must do, in plain language.', - type: 'string', - }, - service: { - description: - 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', - type: 'string', - }, - }, - required: ['query'], - type: 'object', - }, -} - -export const SearchKnowledgeBase: ToolCatalogEntry = { - id: 'search_knowledge_base', - name: 'search_knowledge_base', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - knowledgeBaseId: { - type: 'string', - description: 'Knowledge base ID (required for all operations)', - }, - query: { type: 'string', description: "Search query text (required for 'query')" }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - }, - }, - operation: { - type: 'string', - description: 'The read operation to perform', - enum: ['get', 'query', 'list_tags'], - }, - }, - 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 SearchLibraryDocs: ToolCatalogEntry = { id: 'search_library_docs', name: 'search_library_docs', @@ -3921,6 +3768,26 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { requiredPermission: 'write', } +export const Superagent: ToolCatalogEntry = { + id: 'superagent', + name: 'superagent', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'superagent', + internal: true, +} + export const Table: ToolCatalogEntry = { id: 'table', name: 'table', @@ -4003,6 +3870,50 @@ export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } +export const UserMemory: ToolCatalogEntry = { + id: 'user_memory', + name: 'user_memory', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + confidence: { + type: 'number', + description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', + }, + correct_value: { + type: 'string', + description: + "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", + }, + key: { + type: 'string', + description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", + }, + limit: { type: 'number', description: 'Number of results for search (default 10)' }, + memory_type: { + type: 'string', + description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", + enum: ['preference', 'entity', 'history', 'correction'], + }, + operation: { + type: 'string', + description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", + enum: ['add', 'search', 'delete', 'correct', 'list'], + }, + query: { type: 'string', description: 'Search query to find relevant memories' }, + source: { + type: 'string', + description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", + enum: ['explicit', 'inferred'], + }, + value: { type: 'string', description: 'Value to remember' }, + }, + required: ['operation'], + }, +} + export const UserTable: ToolCatalogEntry = { id: 'user_table', name: 'user_table', @@ -4345,23 +4256,10 @@ export const Workflow: ToolCatalogEntry = { 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', - }, - 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', - }, - 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, + "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', }, }, - required: ['title'], type: 'object', }, subagentId: 'workflow', @@ -4597,13 +4495,21 @@ export const ManageCustomToolOperationValues = [ ] as const export const ManageFolderOperation = { + create: 'create', + rename: 'rename', + move: 'move', delete: 'delete', } as const export type ManageFolderOperation = (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] -export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const +export const ManageFolderOperationValues = [ + ManageFolderOperation.create, + ManageFolderOperation.rename, + ManageFolderOperation.move, + ManageFolderOperation.delete, +] as const export const ManageMcpToolOperation = { add: 'add', @@ -4670,36 +4576,22 @@ export const MaterializeFileOperationValues = [ MaterializeFileOperation.import, ] as const -export const QueryUserTableOperation = { - get: 'get', - getSchema: 'get_schema', - getRow: 'get_row', - queryRows: 'query_rows', -} as const - -export type QueryUserTableOperation = - (typeof QueryUserTableOperation)[keyof typeof QueryUserTableOperation] - -export const QueryUserTableOperationValues = [ - QueryUserTableOperation.get, - QueryUserTableOperation.getSchema, - QueryUserTableOperation.getRow, - QueryUserTableOperation.queryRows, -] as const - -export const SearchKnowledgeBaseOperation = { - get: 'get', - query: 'query', - listTags: 'list_tags', +export const UserMemoryOperation = { + add: 'add', + search: 'search', + delete: 'delete', + correct: 'correct', + list: 'list', } as const -export type SearchKnowledgeBaseOperation = - (typeof SearchKnowledgeBaseOperation)[keyof typeof SearchKnowledgeBaseOperation] +export type UserMemoryOperation = (typeof UserMemoryOperation)[keyof typeof UserMemoryOperation] -export const SearchKnowledgeBaseOperationValues = [ - SearchKnowledgeBaseOperation.get, - SearchKnowledgeBaseOperation.query, - SearchKnowledgeBaseOperation.listTags, +export const UserMemoryOperationValues = [ + UserMemoryOperation.add, + UserMemoryOperation.search, + UserMemoryOperation.delete, + UserMemoryOperation.correct, + UserMemoryOperation.list, ] as const export const UserTableOperation = { @@ -4790,12 +4682,11 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, - [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteScheduledTask.id]: CompleteScheduledTask, - [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, + [CreateFileFolder.id]: CreateFileFolder, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, [DeleteFile.id]: DeleteFile, @@ -4832,6 +4723,7 @@ export const TOOL_CATALOG: Record = { [Grep.id]: Grep, [Knowledge.id]: Knowledge, [KnowledgeBase.id]: KnowledgeBase, + [ListFileFolders.id]: ListFileFolders, [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, @@ -4845,40 +4737,42 @@ export const TOOL_CATALOG: Record = { [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, - [Mkdir.id]: Mkdir, - [Mv.id]: Mv, + [MoveFile.id]: MoveFile, + [MoveFileFolder.id]: MoveFileFolder, + [MoveWorkflow.id]: MoveWorkflow, [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, [PromoteToLive.id]: PromoteToLive, [QueryLogs.id]: QueryLogs, - [QueryUserTable.id]: QueryUserTable, [Read.id]: Read, [Redeploy.id]: Redeploy, + [RenameFile.id]: RenameFile, + [RenameFileFolder.id]: RenameFileFolder, + [RenameWorkflow.id]: RenameWorkflow, + [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, [Run.id]: Run, [RunBlock.id]: RunBlock, - [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, - [Search.id]: Search, [SearchDocumentation.id]: SearchDocumentation, - [SearchIntegrationTools.id]: SearchIntegrationTools, - [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, [SearchOnline.id]: SearchOnline, [SearchPatterns.id]: SearchPatterns, [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, + [Superagent.id]: Superagent, [Table.id]: Table, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, + [UserMemory.id]: UserMemory, [UserTable.id]: UserTable, [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 34d854aa5f1..2d9678086ff 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,34 +36,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - call_integration_tool: { - parameters: { - properties: { - arguments: { - additionalProperties: true, - description: "Inputs matching the selected operation's server-owned inputSchema.", - type: 'object', - }, - credentialId: { - description: - 'Optional OAuth credential ID convenience field. It is injected into operation arguments when that schema accepts credentialId.', - type: 'string', - }, - description: { - description: - 'Short base-form verb phrase describing this invocation, without the integration name (for example "Search for invoice emails").', - type: 'string', - }, - toolId: { - description: 'Exact toolId returned by search_integration_tools.', - type: 'string', - }, - }, - required: ['toolId', 'description', 'arguments'], - type: 'object', - }, - resultSchema: undefined, - }, check_deployment_status: { parameters: { type: 'object', @@ -89,33 +61,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - cp: { - parameters: { - type: 'object', - properties: { - destination: { - type: 'string', - 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', - description: - 'Canonical workflow VFS paths to duplicate, e.g. ["workflows/My%20Workflow"]. Copy paths verbatim from glob/grep/read output.', - items: { - type: 'string', - }, - }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "My Workflow" or "Template to Archive", not a full sentence like "Copying My Workflow".', - }, - }, - required: ['sources', 'destination', 'toolTitle'], - }, - resultSchema: undefined, - }, crawl_website: { parameters: { type: 'object', @@ -172,7 +117,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', + 'Files to create or overwrite. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -217,10 +162,32 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + create_file_folder: { + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', + }, + workspaceId: { + type: 'string', + description: 'Optional workspace ID. Defaults to the current workspace.', + }, + }, + required: ['path'], + }, + resultSchema: undefined, + }, create_workflow: { parameters: { type: 'object', properties: { + description: { + type: 'string', + description: 'Optional workflow description.', + }, folderId: { type: 'string', description: 'Optional folder ID.', @@ -644,7 +611,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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', + 'Optional icon image for the block: a workspace file VFS path (e.g. "files/icon.png", copied into public icon storage at publish) or an external image URL. Omit to use the organization\'s default icon', }, inputs: { type: 'array', @@ -887,7 +854,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { files: { type: 'array', description: - 'Files to create or overwrite. Missing parent folders are created automatically for create mode.', + 'Files to create or overwrite. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1157,8 +1124,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1325,8 +1291,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1359,12 +1324,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - timeout: { - type: 'number', - description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', - default: 10, - }, title: { type: 'string', description: @@ -1493,8 +1452,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1631,8 +1589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -1790,8 +1747,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { files: { type: 'array', - description: - 'File outputs. Missing parent folders are created automatically for create mode.', + description: 'File outputs. Parent folders must already exist for create mode.', items: { type: 'object', properties: { @@ -2277,6 +2233,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + list_file_folders: { + parameters: { + type: 'object', + properties: { + workspaceId: { + type: 'string', + description: 'Optional workspace ID. Defaults to the current workspace.', + }, + }, + }, + resultSchema: undefined, + }, list_integration_tools: { parameters: { properties: { @@ -2460,20 +2428,35 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + destinationPath: { + type: 'string', + description: + 'Destination parent folder\'s VFS path for move/create. Omit (or pass "workflows") to target the workspace root.', + }, folderId: { type: 'string', description: 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', }, + name: { + type: 'string', + description: + 'Folder name. Required for rename (the new name); for create when you pass a destination parent instead of a full path.', + }, operation: { type: 'string', description: 'The operation to perform.', - enum: ['delete'], + enum: ['create', 'rename', 'move', 'delete'], + }, + parentId: { + type: 'string', + description: + 'Destination parent folder ID, used as a fallback when destinationPath is not given.', }, path: { type: 'string', description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path. Identifies the folder for rename/move/delete; for create it is the new folder\'s full path (its parent must already exist).', }, }, required: ['operation'], @@ -2677,52 +2660,62 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - mkdir: { + move_file: { parameters: { type: 'object', properties: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, paths: { type: 'array', - description: - 'Canonical folder VFS paths to create, e.g. ["files/Reports/2026"]. Missing parent segments are created automatically.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string', }, }, - toolTitle: { + }, + required: ['paths'], + }, + resultSchema: undefined, + }, + move_file_folder: { + parameters: { + type: 'object', + properties: { + destinationPath: { type: 'string', description: - 'Target-only UI phrase for the action row, e.g. "Reports/2026" or "2 folders", not a full sentence like "Creating Reports".', + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', + }, + path: { + type: 'string', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['paths', 'toolTitle'], + required: ['path'], }, resultSchema: undefined, }, - mv: { + move_workflow: { parameters: { type: 'object', properties: { - destination: { + folderId: { type: 'string', - 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).', + description: 'Target folder ID. Omit or pass empty string to move to workspace root.', }, - sources: { + workflowIds: { type: 'array', - 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.', + description: 'The workflow IDs to move.', items: { type: 'string', }, }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "draft.md to Reports" or "3 files to Images", not a full sentence like "Moving draft.md".', - }, }, - required: ['sources', 'destination', 'toolTitle'], + required: ['workflowIds'], }, resultSchema: undefined, }, @@ -2730,11 +2723,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - credentialId: { - type: 'string', - description: - 'Optional. The id of an EXISTING credential (from environment/credentials.json) to reconnect/re-authorize in place. Only when the user explicitly asks to reconnect or repair that credential — never for adding another account.', - }, providerName: { type: 'string', description: @@ -2923,68 +2911,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - query_user_table: { - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - filter: { - type: 'object', - description: 'MongoDB-style filter for query_rows', - }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', - }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, - rowId: { - type: 'string', - description: 'Row ID (required for get_row)', - }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, - tableId: { - type: 'string', - description: 'Table ID (required for all operations)', - }, - }, - }, - operation: { - type: 'string', - description: 'The read operation to perform', - enum: ['get', 'get_schema', 'get_row', 'query_rows'], - }, - }, - 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'], - }, - }, read: { parameters: { type: 'object', @@ -3091,6 +3017,89 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, + rename_file: { + parameters: { + type: 'object', + properties: { + newName: { + type: 'string', + description: + 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', + }, + path: { + type: 'string', + description: + 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, + }, + required: ['path', 'newName'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: 'Contains id and the new name.', + }, + message: { + type: 'string', + description: 'Human-readable outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the rename succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + rename_file_folder: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'New folder name.', + }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, + }, + required: ['path', 'name'], + }, + resultSchema: undefined, + }, + rename_workflow: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'The new name for the workflow.', + }, + workflowId: { + type: 'string', + description: 'The workflow ID to rename.', + }, + }, + required: ['workflowId', 'name'], + }, + resultSchema: undefined, + }, + research: { + parameters: { + properties: { + topic: { + description: 'The topic to research.', + type: 'string', + }, + }, + required: ['topic'], + type: 'object', + }, + resultSchema: undefined, + }, respond: { parameters: { additionalProperties: true, @@ -3100,14 +3109,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'The result — facts, status, VFS paths to persisted data, whatever the caller needs to act on.', type: 'string', }, - paths: { - description: - 'Affected VFS file paths. Required when the File Agent reports a successful file mutation.', - items: { - type: 'string', - }, - type: 'array', - }, success: { description: 'Whether the task completed successfully', type: 'boolean', @@ -3189,99 +3190,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, 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 access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', - }, - 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_from_block: { parameters: { type: 'object', @@ -3429,20 +3337,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search: { - parameters: { - properties: { - task: { - description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - resultSchema: undefined, - }, search_documentation: { parameters: { type: 'object', @@ -3460,80 +3354,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_integration_tools: { - parameters: { - properties: { - limit: { - description: 'Maximum matches to return. Defaults to 5.', - maximum: 10, - minimum: 1, - type: 'integer', - }, - query: { - description: 'What the service operation must do, in plain language.', - type: 'string', - }, - service: { - description: - 'Optional canonical service name, such as "gmail", "slack", or "google_sheets".', - type: 'string', - }, - }, - required: ['query'], - type: 'object', - }, - resultSchema: undefined, - }, - search_knowledge_base: { - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Arguments for the operation', - properties: { - knowledgeBaseId: { - type: 'string', - description: 'Knowledge base ID (required for all operations)', - }, - query: { - type: 'string', - description: "Search query text (required for 'query')", - }, - topK: { - type: 'number', - description: 'Number of results to return (1-50, default: 5)', - default: 5, - }, - }, - }, - operation: { - type: 'string', - description: 'The read operation to perform', - enum: ['get', 'query', 'list_tags'], - }, - }, - 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'], - }, - }, search_library_docs: { parameters: { type: 'object', @@ -3714,6 +3534,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + superagent: { + parameters: { + properties: { + task: { + description: + "A single sentence — the agent has full conversation context. Do NOT pre-read credentials or look up configs. Example: 'send the email we discussed' or 'check my calendar for tomorrow'.", + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, table: { parameters: { properties: { @@ -3799,6 +3633,55 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + user_memory: { + parameters: { + type: 'object', + properties: { + confidence: { + type: 'number', + description: 'Confidence level 0-1 (default 1.0 for explicit, 0.8 for inferred)', + }, + correct_value: { + type: 'string', + description: + "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", + }, + key: { + type: 'string', + description: "Unique key for the memory (e.g., 'preferred_model', 'slack_credential')", + }, + limit: { + type: 'number', + description: 'Number of results for search (default 10)', + }, + memory_type: { + type: 'string', + description: "Type of memory: 'preference', 'entity', 'history', or 'correction'", + enum: ['preference', 'entity', 'history', 'correction'], + }, + operation: { + type: 'string', + description: "Operation: 'add', 'search', 'delete', 'correct', or 'list'", + enum: ['add', 'search', 'delete', 'correct', 'list'], + }, + query: { + type: 'string', + description: 'Search query to find relevant memories', + }, + source: { + type: 'string', + description: "Source: 'explicit' (user told you) or 'inferred' (you observed)", + enum: ['explicit', 'inferred'], + }, + value: { + type: 'string', + description: 'Value to remember', + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, user_table: { parameters: { type: 'object', @@ -4167,23 +4050,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 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', - }, - 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', - }, - 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, + "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', }, }, - required: ['title'], type: 'object', }, resultSchema: undefined, diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts index 57cb552726e..9a4df7519b1 100644 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts @@ -113,6 +113,7 @@ export interface VfsSnapshotV1Table { * via the `definition` "VfsSnapshotV1Workflow". */ export interface VfsSnapshotV1Workflow { + description?: string folderPath?: string id: string isDeployed?: boolean diff --git a/apps/sim/lib/copilot/integration-tools.test.ts b/apps/sim/lib/copilot/integration-tools.test.ts deleted file mode 100644 index d55e6f15f0a..00000000000 --- a/apps/sim/lib/copilot/integration-tools.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/blocks/registry-maps', () => ({ - BLOCK_REGISTRY: { - svc: { - type: 'svc', - tools: { access: ['svc_send_v2'] }, - }, - // Preview successor sharing the released block's tools (the slack/slack_v2 - // paradigm) — must not become the owner of the shared tools. - svc_v2: { - type: 'svc_v2', - preview: true, - tools: { access: ['svc_send_v2'] }, - }, - // Preview block with tools of its own — stays preview-owned and gated. - newsvc: { - type: 'newsvc', - preview: true, - tools: { access: ['newsvc_do_v1'] }, - }, - }, -})) - -vi.mock('@/tools/registry', () => ({ - tools: { - svc_send_v1: { name: 'Send (legacy)' }, - svc_send_v2: { name: 'Send' }, - newsvc_do_v1: { name: 'Do' }, - }, -})) - -import { - filterExposedIntegrationTools, - getExposedIntegrationTools, - resetExposedIntegrationToolsCache, -} from '@/lib/copilot/integration-tools' - -describe('getExposedIntegrationTools', () => { - beforeEach(() => { - resetExposedIntegrationToolsCache() - }) - - it('keeps the released block as owner of tools a preview block shares', () => { - const send = getExposedIntegrationTools().find((t) => t.toolId === 'svc_send_v2') - expect(send).toBeDefined() - expect(send?.blockType).toBe('svc') - expect(send?.preview).toBeFalsy() - }) - - it('exposes shared tools to viewers without the preview reveal, but not preview-only tools', () => { - const visible = filterExposedIntegrationTools(getExposedIntegrationTools(), null) - expect(visible.some((t) => t.toolId === 'svc_send_v2')).toBe(true) - expect(visible.some((t) => t.toolId === 'newsvc_do_v1')).toBe(false) - }) - - it('exposes only the latest version of each tool', () => { - const exposed = getExposedIntegrationTools() - expect(exposed.some((t) => t.toolId === 'svc_send_v1')).toBe(false) - expect(exposed.some((t) => t.toolId === 'svc_send_v2')).toBe(true) - }) -}) diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/copilot/integration-tools.ts index 79b92e53bb5..77559d78784 100644 --- a/apps/sim/lib/copilot/integration-tools.ts +++ b/apps/sim/lib/copilot/integration-tools.ts @@ -53,15 +53,8 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] { const service = stripVersionSuffix(block.type) const owner = { service, blockType: block.type, preview: block.preview } for (const toolId of block.tools.access) { - for (const key of [toolId, stripVersionSuffix(toolId)]) { - // A preview block must not steal ownership of tools it shares with a - // released block (e.g. slack_v2 spreads slack's tools.access), or the - // per-viewer filter would hide those tools from everyone without the - // preview reveal. - const existing = toolToBlock.get(key) - if (existing && !existing.preview && owner.preview) continue - toolToBlock.set(key, owner) - } + toolToBlock.set(toolId, owner) + toolToBlock.set(stripVersionSuffix(toolId), owner) } } diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/copilot/mcp-tools.test.ts deleted file mode 100644 index a41057d6d8d..00000000000 --- a/apps/sim/lib/copilot/mcp-tools.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { discoverServerTools, validateMcpToolsAllowed } = vi.hoisted(() => ({ - discoverServerTools: vi.fn(), - validateMcpToolsAllowed: vi.fn(), -})) - -vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) -vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateMcpToolsAllowed })) - -import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from './mcp-tools' - -describe('mothership MCP tool schemas', () => { - beforeEach(() => { - vi.clearAllMocks() - validateMcpToolsAllowed.mockResolvedValue(undefined) - }) - - it('discovers tools only for explicitly tagged servers', async () => { - discoverServerTools.mockResolvedValue([ - { - serverId: 'mcp-server-1', - serverName: 'Docs', - name: 'search', - description: 'Search docs', - inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, - }, - ]) - - const tools = await buildTaggedMcpToolSchemas('user-1', 'ws-1', ['mcp-server-1']) - - expect(discoverServerTools).toHaveBeenCalledTimes(1) - expect(discoverServerTools).toHaveBeenCalledWith('user-1', 'mcp-server-1', 'ws-1') - expect(tools).toEqual([ - expect.objectContaining({ - name: 'mcp-server-1-search', - defer_loading: true, - executeLocally: false, - params: expect.objectContaining({ - mothershipToolKind: 'mcp', - mothershipToolName: 'mcp-server-1-search', - serverId: 'mcp-server-1', - toolName: 'search', - }), - }), - ]) - }) - - it('uses a selected block tool cached schema without discovering the server', async () => { - const tools = await buildSelectedMcpToolSchemas('user-1', 'ws-1', [ - { - type: 'mcp', - params: { serverId: 'mcp-server-1', toolName: 'search', serverName: 'Docs' }, - schema: { type: 'object', properties: { query: { type: 'string' } } }, - }, - ]) - - expect(discoverServerTools).not.toHaveBeenCalled() - expect(tools[0]).toMatchObject({ - name: 'mcp-server-1-search', - input_schema: { type: 'object', properties: { query: { type: 'string' } } }, - }) - }) -}) diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/copilot/mcp-tools.ts deleted file mode 100644 index 0316e1eb55d..00000000000 --- a/apps/sim/lib/copilot/mcp-tools.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import type { ToolSchema } from '@/lib/copilot/chat/payload' -import type { McpTool, McpToolSchema } from '@/lib/mcp/types' -import { createMcpToolId } from '@/lib/mcp/utils' -import { validateMcpToolsAllowed } from '@/ee/access-control/utils/permission-check' -import type { ToolInput } from '@/executor/handlers/agent/types' - -const logger = createLogger('CopilotMcpTools') - -function toMothershipMcpTool(tool: { - serverId: string - serverName?: string - name: string - description?: string - inputSchema: McpToolSchema | Record -}): ToolSchema { - const callableName = createMcpToolId(tool.serverId, tool.name) - return { - name: callableName, - description: - tool.description || `MCP tool ${tool.name} from ${tool.serverName || tool.serverId}`, - input_schema: tool.inputSchema, - defer_loading: true, - executeLocally: false, - service: `mcp:${tool.serverId}`, - params: { - mothershipToolKind: 'mcp', - mothershipToolName: callableName, - mothershipToolTitle: tool.serverName ? `${tool.serverName}: ${tool.name}` : tool.name, - serverId: tool.serverId, - toolName: tool.name, - }, - } -} - -function dedupeMcpTools(tools: ToolSchema[]): ToolSchema[] { - const seen = new Set() - return tools.filter((tool) => { - if (seen.has(tool.name)) return false - seen.add(tool.name) - return true - }) -} - -async function discoverServerTools( - userId: string, - workspaceId: string, - serverId: string -): Promise { - try { - const { mcpService } = await import('@/lib/mcp/service') - return await mcpService.discoverServerTools(userId, serverId, workspaceId) - } catch (error) { - logger.warn('Failed to resolve tagged MCP server tools', { - serverId, - workspaceId, - error: toError(error).message, - }) - return [] - } -} - -/** - * Resolves every tool from explicitly tagged MCP servers into request-local, - * deferred tool schemas. Untagged workspace servers are never inspected. - */ -export async function buildTaggedMcpToolSchemas( - userId: string, - workspaceId: string, - serverIds: string[] -): Promise { - const uniqueServerIds = [...new Set(serverIds.filter(Boolean))] - if (uniqueServerIds.length === 0) return [] - - await validateMcpToolsAllowed(userId, workspaceId) - const discovered = await Promise.all( - uniqueServerIds.map((serverId) => discoverServerTools(userId, workspaceId, serverId)) - ) - return dedupeMcpTools(discovered.flat().map(toMothershipMcpTool)) -} - -/** - * Resolves the individual MCP tools selected on a Mothership block. Cached - * editor schemas are used directly; legacy selections without a schema fall - * back to one discovery call per selected server. - */ -export async function buildSelectedMcpToolSchemas( - userId: string, - workspaceId: string, - selections: ToolInput[] -): Promise { - const selected = selections.filter( - (tool) => - tool.type === 'mcp' && - (tool.usageControl || 'auto') !== 'none' && - typeof tool.params?.serverId === 'string' && - typeof tool.params?.toolName === 'string' - ) - if (selected.length === 0) return [] - - await validateMcpToolsAllowed(userId, workspaceId) - const discoveredByServer = new Map>() - const resolved = await Promise.all( - selected.map(async (selection) => { - const serverId = selection.params!.serverId as string - const toolName = selection.params!.toolName as string - const serverName = - typeof selection.params!.serverName === 'string' - ? (selection.params!.serverName as string) - : undefined - - if (selection.schema && typeof selection.schema === 'object') { - return toMothershipMcpTool({ - serverId, - serverName, - name: toolName, - description: - typeof selection.schema.description === 'string' - ? selection.schema.description - : undefined, - inputSchema: selection.schema as Record, - }) - } - - let discovery = discoveredByServer.get(serverId) - if (!discovery) { - discovery = discoverServerTools(userId, workspaceId, serverId) - discoveredByServer.set(serverId, discovery) - } - const match = (await discovery).find((tool) => tool.name === toolName) - return match ? toMothershipMcpTool(match) : null - }) - ) - - return dedupeMcpTools(resolved.filter((tool): tool is ToolSchema => tool !== null)) -} diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index da5991703eb..7d341238c4e 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -75,7 +75,6 @@ describe('sse-handlers tool lifecycle', () => { beforeEach(() => { vi.clearAllMocks() - isSimExecuted.mockReturnValue(true) upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) @@ -559,103 +558,6 @@ describe('sse-handlers tool lifecycle', () => { expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1') }) - it('pairs compaction lifecycle events within each scoped subagent lane', async () => { - context.toolCalls.set('parent-A', { - id: 'parent-A', - name: 'workflow', - status: 'executing', - }) - context.toolCalls.set('parent-B', { - id: 'parent-B', - name: 'workflow', - status: 'executing', - }) - const sendCompaction = async ( - kind: 'compaction_start' | 'compaction_done', - parentToolCallId: string, - spanId: string - ) => { - await subAgentHandlers.run( - { - type: MothershipStreamV1EventType.run, - scope: { - lane: 'subagent', - parentToolCallId, - spanId, - parentSpanId: 'main', - agentId: 'superagent', - }, - payload: { kind }, - } as StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - } - - await sendCompaction(MothershipStreamV1RunKind.compaction_start, 'parent-A', 'span-A') - await sendCompaction(MothershipStreamV1RunKind.compaction_start, 'parent-B', 'span-B') - await sendCompaction(MothershipStreamV1RunKind.compaction_done, 'parent-A', 'span-A') - - const compactions = context.contentBlocks.filter( - (block) => block.type === 'tool_call' && block.toolCall?.name === 'context_compaction' - ) - expect(compactions).toHaveLength(2) - - const laneA = compactions.find((block) => block.spanId === 'span-A') - const laneB = compactions.find((block) => block.spanId === 'span-B') - expect(laneA).toEqual( - expect.objectContaining({ - calledBy: 'workflow', - parentToolCallId: 'parent-A', - parentSpanId: 'main', - endedAt: expect.any(Number), - toolCall: expect.objectContaining({ status: MothershipStreamV1ToolOutcome.success }), - }) - ) - expect(laneB?.toolCall?.status).toBe('executing') - - await sendCompaction(MothershipStreamV1RunKind.compaction_done, 'parent-B', 'span-B') - - expect(context.contentBlocks).toHaveLength(2) - expect(laneB?.toolCall?.status).toBe(MothershipStreamV1ToolOutcome.success) - }) - - it('pairs main-lane compaction start and done into one completed block', async () => { - await sseHandlers.run( - { - type: MothershipStreamV1EventType.run, - payload: { kind: MothershipStreamV1RunKind.compaction_start }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false } - ) - const compactionId = context.contentBlocks[0]?.toolCall?.id - - await sseHandlers.run( - { - type: MothershipStreamV1EventType.run, - payload: { kind: MothershipStreamV1RunKind.compaction_done }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false } - ) - - expect(context.contentBlocks).toHaveLength(1) - expect(context.contentBlocks[0]).toEqual( - expect.objectContaining({ - endedAt: expect.any(Number), - toolCall: expect.objectContaining({ - id: compactionId, - name: 'context_compaction', - status: MothershipStreamV1ToolOutcome.success, - }), - }) - ) - }) - it('keeps two concurrent subagent lanes separate for text and thinking', async () => { const send = (parent: string, channel: MothershipStreamV1TextChannel, text: string) => subAgentHandlers.text( @@ -1029,127 +931,6 @@ describe('sse-handlers tool lifecycle', () => { ) }) - it('rebinds a gateway call to the resolved integration operation and branded activity', async () => { - isSimExecuted.mockReturnValue(false) - executeTool.mockResolvedValueOnce({ success: true, output: { emails: [] } }) - - await sseHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'gateway-gmail', - toolName: 'call_integration_tool', - executor: MothershipStreamV1ToolExecutor.go, - mode: MothershipStreamV1ToolMode.sync, - phase: MothershipStreamV1ToolPhase.call, - status: 'generating', - partial: true, - }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - - expect(context.toolCalls.get('gateway-gmail')).toEqual( - expect.objectContaining({ - name: 'call_integration_tool', - displayTitle: 'Calling integration', - }) - ) - - for (const argumentsDelta of [ - '{"toolId":"gmail_read_v2",', - '"description":"Searching for invoice emails",', - ]) { - await sseHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'gateway-gmail', - toolName: 'call_integration_tool', - argumentsDelta, - executor: MothershipStreamV1ToolExecutor.go, - mode: MothershipStreamV1ToolMode.sync, - phase: MothershipStreamV1ToolPhase.args_delta, - }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - } - - expect(context.toolCalls.get('gateway-gmail')).toEqual( - expect.objectContaining({ - name: 'call_integration_tool', - displayTitle: 'Searching for invoice emails', - streamingArgs: '{"toolId":"gmail_read_v2","description":"Searching for invoice emails",', - }) - ) - - await sseHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'gateway-gmail', - toolName: 'call_integration_tool', - arguments: { - toolId: 'gmail_read_v2', - description: 'Searching for invoice emails', - arguments: { maxResults: 10 }, - }, - executor: MothershipStreamV1ToolExecutor.go, - mode: MothershipStreamV1ToolMode.sync, - phase: MothershipStreamV1ToolPhase.call, - }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - - expect(context.toolCalls.get('gateway-gmail')).toEqual( - expect.objectContaining({ - name: 'call_integration_tool', - displayTitle: 'Searching for invoice emails', - }) - ) - - await sseHandlers.tool( - { - type: MothershipStreamV1EventType.tool, - payload: { - toolCallId: 'gateway-gmail', - toolName: 'gmail_read_v2', - arguments: { maxResults: 10, credentialId: 'cred-gmail' }, - executor: MothershipStreamV1ToolExecutor.sim, - mode: MothershipStreamV1ToolMode.async, - phase: MothershipStreamV1ToolPhase.call, - }, - } satisfies StreamEvent, - context, - execContext, - { interactive: false, timeout: 1000 } - ) - - await sleep(0) - - expect(executeTool).toHaveBeenCalledTimes(1) - expect(executeTool).toHaveBeenCalledWith( - 'gmail_read_v2', - { maxResults: 10, credentialId: 'cred-gmail' }, - expect.any(Object) - ) - expect(context.toolCalls.get('gateway-gmail')).toEqual( - expect.objectContaining({ - name: 'gmail_read_v2', - displayTitle: 'Searching for invoice emails', - params: { maxResults: 10, credentialId: 'cred-gmail' }, - }) - ) - }) - it('clears pending continuation state when a run resumes', async () => { context.awaitingAsyncContinuation = { checkpointId: 'cp-1', diff --git a/apps/sim/lib/copilot/request/handlers/index.ts b/apps/sim/lib/copilot/request/handlers/index.ts index 554ae149599..b170f9104b8 100644 --- a/apps/sim/lib/copilot/request/handlers/index.ts +++ b/apps/sim/lib/copilot/request/handlers/index.ts @@ -30,7 +30,6 @@ export const sseHandlers: Record = { export const subAgentHandlers: Record = { [MothershipStreamV1EventType.text]: handleTextEvent('subagent'), [MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'subagent'), - [MothershipStreamV1EventType.run]: handleRunEvent, [MothershipStreamV1EventType.span]: handleSpanEvent, } diff --git a/apps/sim/lib/copilot/request/handlers/run.ts b/apps/sim/lib/copilot/request/handlers/run.ts index 9f162be0b7d..593eecce536 100644 --- a/apps/sim/lib/copilot/request/handlers/run.ts +++ b/apps/sim/lib/copilot/request/handlers/run.ts @@ -1,69 +1,12 @@ import { createLogger } from '@sim/logger' -import { generateShortId } from '@sim/utils/id' import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, } from '@/lib/copilot/generated/mothership-stream-v1' -import type { ContentBlock, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' import type { StreamHandler } from './types' -import { addContentBlock, getScopedSpanIdentity } from './types' +import { addContentBlock } from './types' const logger = createLogger('CopilotRunHandler') -const CONTEXT_COMPACTION_TOOL = 'context_compaction' - -function isSameCompactionLane(block: ContentBlock, event: StreamEvent): boolean { - const spanId = event.scope?.spanId - if (spanId) return block.spanId === spanId - - const parentToolCallId = event.scope?.parentToolCallId - if (parentToolCallId) return block.parentToolCallId === parentToolCallId - - return !block.spanId && !block.parentToolCallId && !block.calledBy -} - -function findActiveCompactionBlock( - context: StreamingContext, - event: StreamEvent -): ContentBlock | undefined { - for (let i = context.contentBlocks.length - 1; i >= 0; i--) { - const block = context.contentBlocks[i] - if ( - block.type === 'tool_call' && - block.toolCall?.name === CONTEXT_COMPACTION_TOOL && - block.toolCall.status === 'executing' && - isSameCompactionLane(block, event) - ) { - return block - } - } - return undefined -} - -function addCompactionBlock( - context: StreamingContext, - event: StreamEvent, - status: 'executing' | typeof MothershipStreamV1ToolOutcome.success -): void { - const now = Date.now() - const parentToolCallId = event.scope?.parentToolCallId - const calledBy = - (parentToolCallId ? context.toolCalls.get(parentToolCallId)?.name : undefined) ?? - event.scope?.agentId - addContentBlock(context, { - type: 'tool_call', - toolCall: { - id: `compaction-${generateShortId()}`, - name: CONTEXT_COMPACTION_TOOL, - status, - startTime: now, - ...(status === MothershipStreamV1ToolOutcome.success ? { endTime: now } : {}), - }, - ...(calledBy ? { calledBy } : {}), - ...(parentToolCallId ? { parentToolCallId } : {}), - ...getScopedSpanIdentity(event), - ...(status === MothershipStreamV1ToolOutcome.success ? { endedAt: now } : {}), - }) -} export const handleRunEvent: StreamHandler = (event, context) => { if (event.type !== 'run') { @@ -99,9 +42,14 @@ export const handleRunEvent: StreamHandler = (event, context) => { } if (event.payload.kind === MothershipStreamV1RunKind.compaction_start) { - if (!findActiveCompactionBlock(context, event)) { - addCompactionBlock(context, event, 'executing') - } + addContentBlock(context, { + type: 'tool_call', + toolCall: { + id: `compaction-${Date.now()}`, + name: 'context_compaction', + status: 'executing', + }, + }) return } @@ -113,15 +61,13 @@ export const handleRunEvent: StreamHandler = (event, context) => { } if (event.payload.kind === MothershipStreamV1RunKind.compaction_done) { - const active = findActiveCompactionBlock(context, event) - if (!active?.toolCall) { - addCompactionBlock(context, event, MothershipStreamV1ToolOutcome.success) - return - } - - const now = Date.now() - active.toolCall.status = MothershipStreamV1ToolOutcome.success - active.toolCall.endTime = now - active.endedAt = now + addContentBlock(context, { + type: 'tool_call', + toolCall: { + id: `compaction-${Date.now()}`, + name: 'context_compaction', + status: MothershipStreamV1ToolOutcome.success, + }, + }) } } diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 8f110a82b28..16fbc9b6ead 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -33,7 +33,6 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { type: 'subagent_thinking', content: '', parentToolCallId, - ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, timestamp: Date.now(), } @@ -54,7 +53,6 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { type: 'subagent_text', content: chunk, parentToolCallId, - ...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}), ...spanIdentity, }) return diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 0e30aaee0b2..be028b2b1fc 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -30,10 +30,8 @@ import type { } from '@/lib/copilot/request/types' import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' -import { getBlockByToolName } from '@/blocks/registry' import type { ToolScope } from './types' import { abortPendingToolIfStreamDead, @@ -55,91 +53,12 @@ const logger = createLogger('CopilotToolHandler') function applyToolDisplay(toolCall: ToolCallState | undefined): void { if (!toolCall?.name) return - // Integration rows show only the model-authored activity phrase; the trusted - // integration branding is the icon, derived client-side from the operation - // name (or streamed toolId) via the block registry. With no description yet, - // fall back to the integration name so the humanized gateway name never - // renders. - if (toolCall.name === INTEGRATION_GATEWAY_TOOL) { - const toolId = toolCall.params?.toolId - const description = toolCall.params?.description - if (typeof description === 'string' && description.trim()) { - toolCall.displayTitle = description.trim() - return - } - if (typeof toolId === 'string') { - const integration = getBlockByToolName(toolId) - if (integration) { - toolCall.displayTitle = integration.name - return - } - } - } - if (toolCall.integrationDescription) { - toolCall.displayTitle = toolCall.integrationDescription - return - } toolCall.displayTitle = getToolDisplayTitle( toolCall.name, toolCall.params as Record | undefined ) } -const INTEGRATION_GATEWAY_TOOL = 'call_integration_tool' - -function handleToolArgsDelta( - data: { argumentsDelta: string; toolCallId: string; toolName: string }, - context: StreamingContext -): void { - const toolCall = context.toolCalls.get(data.toolCallId) - if (!toolCall) return - toolCall.streamingArgs = `${toolCall.streamingArgs ?? ''}${data.argumentsDelta}` - if (toolCall.name !== INTEGRATION_GATEWAY_TOOL) return - - const toolId = extractStreamingStringArgument(toolCall.streamingArgs, 'toolId') - const description = extractStreamingStringArgument(toolCall.streamingArgs, 'description') - if (toolId || description) { - toolCall.params = { - ...toolCall.params, - ...(toolId ? { toolId } : {}), - ...(description ? { description } : {}), - } - applyToolDisplay(toolCall) - } -} - -/** - * The model first streams the stable gateway call. Once Go resolves its exact - * server-owned operation, a second authoritative frame with the same call id - * carries the real executable tool name and arguments. Rebind atomically so - * execution, persistence, branding, and results all use that operation while - * retaining only the model-authored activity description for presentation. - */ -function rebindResolvedIntegrationCall( - toolCall: ToolCallState | undefined, - toolName: string, - args: Record | undefined -): boolean { - if (!toolCall || toolCall.name !== INTEGRATION_GATEWAY_TOOL) { - return false - } - // Gateway arguments may arrive over several generating/final frames. Keep - // the newest complete snapshot so the eventual authoritative operation frame - // can retain the model-authored presentation description. - if (toolName === INTEGRATION_GATEWAY_TOOL) { - if (args) toolCall.params = args - return true - } - const description = toolCall.params?.description - if (typeof description === 'string' && description.trim()) { - toolCall.integrationDescription = description.trim() - } - toolCall.name = toolName - toolCall.params = args - applyToolDisplay(toolCall) - return true -} - /** * Upsert the durable `async_tool_calls` row before the authoritative tool-call * SSE frame is forwarded to the client, so `/api/copilot/confirm` can never @@ -212,7 +131,6 @@ export async function handleToolEvent( } if (isToolArgsDeltaStreamEvent(event)) { - handleToolArgsDelta(event.payload, context) return } @@ -342,10 +260,8 @@ async function handleCallPhase( if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { - if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (existing && !existing.name && toolName) existing.name = toolName - if (existing && !existing.params && args) existing.params = args - } + if (existing && !existing.name && toolName) existing.name = toolName + if (existing && !existing.params && args) existing.params = args applyToolDisplay(existing) return } @@ -354,10 +270,8 @@ async function handleCallPhase( existing?.endTime || (existing && existing.status !== 'pending' && existing.status !== 'executing') ) { - if (!rebindResolvedIntegrationCall(existing, toolName, args)) { - if (!existing.name && toolName) existing.name = toolName - if (!existing.params && args) existing.params = args - } + if (!existing.name && toolName) existing.name = toolName + if (!existing.params && args) existing.params = args applyToolDisplay(existing) return } @@ -461,10 +375,8 @@ function registerSubagentToolCall( const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { - if (!rebindResolvedIntegrationCall(toolCall, toolName, args)) { - if (!toolCall.name && toolName) toolCall.name = toolName - if (args && !toolCall.params) toolCall.params = args - } + if (!toolCall.name && toolName) toolCall.name = toolName + if (args && !toolCall.params) toolCall.params = args applyToolDisplay(toolCall) if (hideFromUi) removeToolCallContentBlock(context, toolCallId) } else { @@ -492,10 +404,8 @@ function registerSubagentToolCall( const subagentToolCalls = context.subAgentToolCalls[parentToolCallId] const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) if (existingSubagentToolCall) { - if (!rebindResolvedIntegrationCall(existingSubagentToolCall, toolName, args)) { - if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName - if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args - } + if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName + if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args applyToolDisplay(existingSubagentToolCall) } else { subagentToolCalls.push(toolCall) @@ -512,9 +422,7 @@ function registerMainToolCall( ): void { const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { - if (!rebindResolvedIntegrationCall(existing, toolName, args) && args && !existing.params) { - existing.params = args - } + if (args && !existing.params) existing.params = args applyToolDisplay(existing) if (hideFromUi) { removeToolCallContentBlock(context, toolCallId) diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts index 65b5b4319c4..585cee9d18c 100644 --- a/apps/sim/lib/copilot/request/sse-utils.test.ts +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -40,23 +40,6 @@ describe('shouldSkipToolCallEvent', () => { ) ).toBe(false) }) - - it('allows a gateway call id to rebind to its resolved integration operation', () => { - const callId = 'gateway-resolve-call' - const gateway = toolCallEvent(callId, 'call_integration_tool', { - toolId: 'gmail_read_v2', - description: 'Reading recent emails', - arguments: {}, - }) - const resolved = toolCallEvent(callId, 'gmail_read_v2', { - credentialId: 'cred-gmail', - }) - - expect(shouldSkipToolCallEvent(gateway)).toBe(false) - expect(shouldSkipToolCallEvent(gateway)).toBe(true) - expect(shouldSkipToolCallEvent(resolved)).toBe(false) - expect(shouldSkipToolCallEvent(resolved)).toBe(true) - }) }) function toolCallEvent( diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/copilot/request/sse-utils.ts index 47fa1f5b549..310c1b9eaa8 100644 --- a/apps/sim/lib/copilot/request/sse-utils.ts +++ b/apps/sim/lib/copilot/request/sse-utils.ts @@ -37,16 +37,12 @@ function getToolCallIdFromResultEvent(event: ToolResultStreamEvent): string { return event.payload.toolCallId } -function toolCallDedupeKey(toolCallId: string, toolName: string): string { - return `${toolCallId}\u0000${toolName}` +function markToolCallSeen(toolCallId: string): void { + addToSet(seenToolCalls, toolCallId) } -function markToolCallSeen(toolCallId: string, toolName: string): void { - addToSet(seenToolCalls, toolCallDedupeKey(toolCallId, toolName)) -} - -function wasToolCallSeen(toolCallId: string, toolName: string): boolean { - return seenToolCalls.has(toolCallDedupeKey(toolCallId, toolName)) +function wasToolCallSeen(toolCallId: string): boolean { + return seenToolCalls.has(toolCallId) } export function markToolResultSeen(toolCallId: string): void { @@ -63,13 +59,8 @@ export function shouldSkipToolCallEvent(event: StreamEvent): boolean { if (event.payload.status === TOOL_CALL_STATUS.generating) return false const toolCallId = getToolCallIdFromCallEvent(event) if (event.payload.partial === true) return false - const toolName = event.payload.toolName - // A resolved integration gateway intentionally emits two authoritative - // call frames under one provider call ID: first call_integration_tool, then - // the exact request-local operation. Deduplicate retransmits of the same - // frame, but allow the name transition through to execution and UI rebinding. - if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId, toolName)) return true - markToolCallSeen(toolCallId, toolName) + if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) return true + markToolCallSeen(toolCallId) return false } diff --git a/apps/sim/lib/copilot/request/tool-call-state.test.ts b/apps/sim/lib/copilot/request/tool-call-state.test.ts deleted file mode 100644 index 7f5c3dd008a..00000000000 --- a/apps/sim/lib/copilot/request/tool-call-state.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ - -import { describe, expect, it } from 'vitest' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import type { ToolCallState } from '@/lib/copilot/request/types' -import { getToolCallTerminalData } from './tool-call-state' - -describe('getToolCallTerminalData', () => { - it('reduces a successful generate_api_key result to only its status message', () => { - const tool: ToolCallState = { - id: 't1', - name: 'generate_api_key', - status: MothershipStreamV1ToolOutcome.success, - result: { - success: true, - output: { - id: 'k1', - name: 'prod', - key: 'sk-sim-secret-value', - message: 'API key "prod" created.', - }, - }, - } - - const data = getToolCallTerminalData(tool) - - // The model gets only the status message — no key, no id/name/workspaceId. - expect(data).toBe('API key "prod" created.') - expect(JSON.stringify(data)).not.toContain('sk-sim-secret-value') - expect(JSON.stringify(data)).not.toContain('k1') - }) - - it('passes through other tools output unchanged', () => { - const tool: ToolCallState = { - id: 't2', - name: 'read', - status: MothershipStreamV1ToolOutcome.success, - result: { success: true, output: { content: 'file contents' } }, - } - - expect(getToolCallTerminalData(tool)).toEqual({ content: 'file contents' }) - }) - - it('surfaces the error for a failed generate_api_key without inventing a key', () => { - const tool: ToolCallState = { - id: 't3', - name: 'generate_api_key', - status: MothershipStreamV1ToolOutcome.error, - error: 'name is required', - } - - expect(getToolCallTerminalData(tool)).toEqual({ error: 'name is required' }) - }) -}) diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/copilot/request/tool-call-state.ts index e5f1b833bff..70433429043 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/copilot/request/tool-call-state.ts @@ -1,4 +1,3 @@ -import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolOutcome as TerminalToolCallStatus, @@ -58,43 +57,17 @@ export function requireToolCallError( } export function getToolCallTerminalData( - toolCall: Pick -): unknown { - // getToolCallTerminalData is the single producer of "what the model reads on - // resume", so it is where a tool's result is reduced to its model-facing form — - // e.g. generate_api_key yields only its status message; the generated key never - // reaches the model (it goes to the browser via the SSE tool result instead). - return toolResultForModel(toolCall.name, getToolCallTerminalDataRaw(toolCall)) -} - -function getToolCallTerminalDataRaw( toolCall: Pick ): unknown { const output = getToolCallStateOutput(toolCall) - const failed = !isSuccessfulToolCallStatus(toolCall.status as TerminalToolCallStatus) - if (output !== undefined) { - if (!failed) { - return output - } - /** - * A failed call must always surface its error in the terminal data — this - * is what the model reads on resume. Handlers can fail with an - * empty-but-defined output (the app-tool executor's "Tool not found" ships - * `output: {}`), and preferring that output rendered failures as bare `{}`, - * so the model retried blind instead of reacting to the error. - */ - const error = - typeof toolCall.error === 'string' && toolCall.error.length > 0 - ? toolCall.error - : 'Tool failed without an error message' - if (output && typeof output === 'object' && !Array.isArray(output)) { - return 'error' in output ? output : { ...output, error } - } - return { output, error } + return output } - if (!failed) { + if ( + toolCall.status === MothershipStreamV1ToolOutcome.success || + toolCall.status === MothershipStreamV1ToolOutcome.skipped + ) { return undefined } diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts deleted file mode 100644 index 789f8165fd9..00000000000 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import '@sim/testing/mocks/executor' - -import { describe, expect, it } from 'vitest' -import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' -import { toolWatchdogTimeoutMs } from '@/lib/copilot/request/tools/executor' - -describe('toolWatchdogTimeoutMs', () => { - it('gives request-scoped MCP tools the long-running watchdog', () => { - expect(toolWatchdogTimeoutMs('mcp-363de040-web_search_exa')).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS) - }) - - it('keeps ordinary tools on the strict default watchdog', () => { - expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) - }) -}) diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts index bdb90c3cf48..bb52291515c 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -33,13 +33,12 @@ import { KnowledgeBase, MaterializeFile, Media, + Research, Run, RunBlock, - RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, - Search, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -48,6 +47,7 @@ import { recordSimToolMetric } from '@/lib/copilot/request/metrics' import { withCopilotToolSpan } from '@/lib/copilot/request/otel' import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' import { + getToolCallStateOutput, getToolCallTerminalData, requireToolCallError, setTerminalToolCallState, @@ -68,7 +68,6 @@ import { type ToolCallState, } from '@/lib/copilot/request/types' import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' -import { isMcpTool } from '@/executor/constants' export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' @@ -208,13 +207,12 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ RunWorkflow.id, RunWorkflowUntilBlock.id, FunctionExecute.id, - RunCode.id, GenerateImage.id, GenerateAudio.id, GenerateVideo.id, Ffmpeg.id, Media.id, - Search.id, + Research.id, CrawlWebsite.id, KnowledgeBase.id, DownloadToWorkspaceFile.id, @@ -225,7 +223,7 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ ]) export function toolWatchdogTimeoutMs(toolName: string | undefined): number { - return toolName && (LONG_RUNNING_TOOL_IDS.has(toolName) || isMcpTool(toolName)) + return toolName && LONG_RUNNING_TOOL_IDS.has(toolName) ? TOOL_WATCHDOG_LONG_RUNNING_MS : TOOL_WATCHDOG_DEFAULT_MS } @@ -331,10 +329,7 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.success) { - // getToolCallTerminalData (not raw output) so the completion signal carries - // the model-facing/redacted result — keeps the sim_key out of every path - // that consumes a completion, matching the error branch below. - const data = getToolCallTerminalData(toolCall) + const data = getToolCallStateOutput(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool completed', @@ -343,7 +338,7 @@ function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompl } if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) { - const data = getToolCallTerminalData(toolCall) + const data = getToolCallStateOutput(toolCall) return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.success, message: 'Tool skipped', @@ -654,10 +649,7 @@ async function executeToolAndReportInner( }) if (result.success) { - // Log the model-facing (redacted) view, not result.output — for - // generate_api_key the raw output carries the plaintext key, which must - // never reach application logs. - const raw = getToolCallTerminalData(toolCall) + const raw = result.output const preview = typeof raw === 'string' ? raw.slice(0, 200) diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 139757524c5..ee77129e269 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -164,34 +164,6 @@ describe('maybeWriteOutputToFile', () => { expect(result.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) }) - - it('fails loudly instead of silently skipping declared outputs when workspace context is missing', async () => { - const result = await maybeWriteOutputToFile( - FunctionExecute.id, - { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, - { success: true, output: { result: 'name,age\nAlice,30', stdout: '' } }, - buildContext({ workspaceId: undefined }) - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('NOT written') - // The computed value survives so the model can use it without re-running. - expect(result.output).toEqual({ result: 'name,age\nAlice,30', stdout: '' }) - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() - }) - - 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, - {}, - original, - buildContext({ workspaceId: undefined }) - ) - - expect(result).toBe(original) - expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() - }) }) describe('extractTabularData', () => { diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 5fe59ef72a2..6a2811a6707 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -206,27 +206,11 @@ export async function maybeWriteOutputToFile( ): Promise { if (!result.success || !result.output) return result if (!OUTPUT_PATH_TOOLS.has(toolName)) return result + if (!context.workspaceId || !context.userId) return result const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) if (outputFiles.length === 0) return result - // The tool declared workspace file outputs; passing the successful result - // through without writing them would be a silent no-op the model reads as - // "file written", so fail loudly instead — but keep the computed output so - // the model can still use the value without re-running the tool. - if (!context.workspaceId || !context.userId) { - logger.warn('Failing tool result: declared output files but no workspace context', { - toolName, - outputCount: outputFiles.length, - }) - return { - success: false, - error: - 'Declared output file(s) were NOT written: this tool call has no workspace context. The computed result is included in the output, but it was not saved to any file.', - output: result.output, - } - } - const outputObject = result.output && typeof result.output === 'object' && !Array.isArray(result.output) ? (result.output as Record) diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 406ef9f0953..2efa49fdbcc 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -23,10 +23,6 @@ export interface ToolCallState { name: string status: ToolCallStatus displayTitle?: string - /** Model-authored activity text for a gateway-resolved integration call. */ - integrationDescription?: string - /** Accumulated partial JSON of the arguments while the model streams them. */ - streamingArgs?: string params?: Record result?: ToolCallStateResult error?: string @@ -69,12 +65,6 @@ export interface ContentBlock { timestamp: number endedAt?: number parentToolCallId?: string - /** - * Subagent name for lane blocks (from the event scope's agentId). Persisted - * so a reloaded transcript can rebuild the lane's group even when the - * `subagent` start block is missing (resume legs re-emit text without start). - */ - subagent?: 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 diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index ff97f23cc6f..e2e381f3f31 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -181,90 +181,6 @@ describe('extractResourcesFromToolResult', () => { }) describe('extractDeletedResourcesFromToolResult', () => { - it('extracts every successfully deleted workflow from the batch result', () => { - const resources = extractDeletedResourcesFromToolResult( - 'delete_workflow', - { workflowIds: ['wf-1', 'wf-2', 'wf-failed'] }, - { - deleted: [ - { workflowId: 'wf-1', name: 'First workflow' }, - { workflowId: 'wf-2', name: 'Second workflow' }, - ], - failed: ['wf-failed'], - } - ) - - expect(resources).toEqual([ - { type: 'workflow', id: 'wf-1', title: 'First workflow' }, - { type: 'workflow', id: 'wf-2', title: 'Second workflow' }, - ]) - }) - - it('extracts deleted files from delete_file result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'delete_file', - { paths: ['files/one.md', 'files/two.md'] }, - { - success: true, - data: { - deleted: [ - { id: 'file-1', name: 'one.md' }, - { id: 'file-2', name: 'two.md' }, - ], - failed: [], - }, - } - ) - ).toEqual([ - { type: 'file', id: 'file-1', title: 'one.md' }, - { type: 'file', id: 'file-2', title: 'two.md' }, - ]) - }) - - it('extracts deleted file folders from delete_file_folder result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'delete_file_folder', - { paths: ['files/Archive'] }, - { success: true, data: { folders: 1, files: 2, deletedFolderIds: ['folder-1'] } } - ) - ).toEqual([{ type: 'filefolder', id: 'folder-1', title: 'Folder' }]) - }) - - it('extracts only successfully deleted tables from user_table result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'user_table', - { operation: 'delete', args: { tableIds: ['table-1', 'table-failed'] } }, - { success: true, data: { deleted: ['table-1'], failed: ['table-failed'] } } - ) - ).toEqual([{ type: 'table', id: 'table-1', title: 'Table' }]) - }) - - it('extracts deleted knowledge bases from knowledge_base result data', () => { - expect( - extractDeletedResourcesFromToolResult( - 'knowledge_base', - { operation: 'delete', args: { knowledgeBaseIds: ['kb-1'] } }, - { - success: true, - data: { deleted: [{ id: 'kb-1', name: 'Docs' }], notFound: [] }, - } - ) - ).toEqual([{ type: 'knowledgebase', id: 'kb-1', title: 'Docs' }]) - }) - - it('extracts deleted workflow folders from manage_folder delete results', () => { - expect( - extractDeletedResourcesFromToolResult( - 'manage_folder', - { operation: 'delete', folderId: 'folder-1' }, - { deleted: ['folder-1'], failed: [] } - ) - ).toEqual([{ type: 'folder', id: 'folder-1', title: 'Folder' }]) - }) - it('removes scheduledtask resources on manage_scheduled_task delete', () => { const resources = extractDeletedResourcesFromToolResult( 'manage_scheduled_task', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 7874718ef5b..d9e311f4720 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,8 +1,6 @@ import { CreateFile, CreateWorkflow, - DeleteFile, - DeleteFileFolder, DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, @@ -13,7 +11,6 @@ import { GenerateVideo, Knowledge, KnowledgeBase, - ManageFolder, ManageScheduledTask, UserTable, WorkspaceFile, @@ -244,12 +241,9 @@ export function extractResourcesFromToolResult( const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { [DeleteWorkflow.id]: 'workflow', - [DeleteFile.id]: 'file', - [DeleteFileFolder.id]: 'filefolder', [WorkspaceFile.id]: 'file', [UserTable.id]: 'table', [KnowledgeBase.id]: 'knowledgebase', - [ManageFolder.id]: 'folder', [ManageScheduledTask.id]: 'scheduledtask', } @@ -277,24 +271,8 @@ export function extractDeletedResourcesFromToolResult( switch (toolName) { case DeleteWorkflow.id: { - const deleted = Array.isArray(result.deleted) ? result.deleted : [] - const resources = deleted.flatMap((entry): ChatResource[] => { - const deletedWorkflow = asRecord(entry) - const workflowId = deletedWorkflow.workflowId - if (typeof workflowId !== 'string' || !workflowId) return [] - return [ - { - type: resourceType, - id: workflowId, - title: typeof deletedWorkflow.name === 'string' ? deletedWorkflow.name : 'Workflow', - }, - ] - }) - if (resources.length > 0) return resources - - // Backward compatibility for historical single-workflow tool results. const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) - if (workflowId && result.deleted === true) { + if (workflowId && result.deleted) { return [ { type: resourceType, id: workflowId, title: (result.name as string) || 'Workflow' }, ] @@ -302,31 +280,6 @@ export function extractDeletedResourcesFromToolResult( return [] } - case DeleteFile.id: { - const deleted = Array.isArray(data.deleted) ? data.deleted : [] - return deleted.flatMap((entry): ChatResource[] => { - const deletedFile = asRecord(entry) - const fileId = deletedFile.id - if (typeof fileId !== 'string' || !fileId) return [] - return [ - { - type: resourceType, - id: fileId, - title: typeof deletedFile.name === 'string' ? deletedFile.name : 'File', - }, - ] - }) - } - - case DeleteFileFolder.id: { - const deletedFolderIds = Array.isArray(data.deletedFolderIds) - ? data.deletedFolderIds.filter( - (id): id is string => typeof id === 'string' && id.length > 0 - ) - : [] - return deletedFolderIds.map((id) => ({ type: resourceType, id, title: 'Folder' })) - } - case WorkspaceFile.id: { if (operation !== 'delete') return [] const target = getWorkspaceFileTarget(params) @@ -339,12 +292,6 @@ export function extractDeletedResourcesFromToolResult( case UserTable.id: { if (operation !== 'delete') return [] - const deleted = Array.isArray(data.deleted) - ? data.deleted.filter((id): id is string => typeof id === 'string' && id.length > 0) - : [] - if (deleted.length > 0) { - return deleted.map((id) => ({ type: resourceType, id, title: 'Table' })) - } const tableId = (args.tableId as string) ?? (params?.tableId as string) if (tableId) { return [{ type: resourceType, id: tableId, title: 'Table' }] @@ -354,23 +301,6 @@ export function extractDeletedResourcesFromToolResult( case KnowledgeBase.id: { if (operation !== 'delete') return [] - const deleted = Array.isArray(data.deleted) ? data.deleted : [] - const resources = deleted.flatMap((entry): ChatResource[] => { - const deletedKnowledgeBase = asRecord(entry) - const knowledgeBaseId = deletedKnowledgeBase.id - if (typeof knowledgeBaseId !== 'string' || !knowledgeBaseId) return [] - return [ - { - type: resourceType, - id: knowledgeBaseId, - title: - typeof deletedKnowledgeBase.name === 'string' - ? deletedKnowledgeBase.name - : 'Knowledge Base', - }, - ] - }) - if (resources.length > 0) return resources const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) if (kbId) { return [{ type: resourceType, id: kbId, title: (data.name as string) || 'Knowledge Base' }] @@ -378,14 +308,6 @@ export function extractDeletedResourcesFromToolResult( return [] } - case ManageFolder.id: { - if (operation !== 'delete') return [] - const deletedIds = Array.isArray(result.deleted) ? (result.deleted as unknown[]) : [] - return deletedIds.flatMap((id): ChatResource[] => - typeof id === 'string' && id ? [{ type: resourceType, id, title: 'Folder' }] : [] - ) - } - case ManageScheduledTask.id: { if (operation !== 'delete') return [] const deletedIds = Array.isArray(result.deleted) ? (result.deleted as string[]) : [] diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 10e78383c7c..61733f43a95 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -119,31 +119,6 @@ describe('copilot tool executor fallback', () => { ) }) - it('converts function_execute 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) - - const context = { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'ws-1', - copilotToolExecution: true, - } - await executeTool('function_execute', { code: 'return 1', timeout: 7 }, context) - - expect(handler).toHaveBeenCalledWith( - expect.objectContaining({ - code: 'return 1', - timeout: 7000, - }), - context - ) - expect(executeAppTool).not.toHaveBeenCalled() - }) - it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 476f29c93a2..d6f7d5caae8 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -44,8 +44,6 @@ export async function executeTool( params: Record, context: ToolExecutionContext ): Promise { - const normalizedParams = normalizeToolParams(toolId, params, context) - // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never // reach this point in interactive mode. In headless mode (Mothership block, no browser) there // is no client to delegate to, so fall back to the registered server-side handler when one @@ -54,7 +52,7 @@ export async function executeTool( isKnownTool(toolId) && (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { - const appParams = buildAppToolParams(normalizedParams, context) + const appParams = buildAppToolParams(toolId, params, context) return executeAppTool(toolId, appParams) } @@ -73,7 +71,7 @@ export async function executeTool( } try { - return await handler(normalizedParams, context) + return await handler(params, context) } catch (error) { const message = toError(error).message logger.error('Tool execution failed', { @@ -85,33 +83,6 @@ export async function executeTool( } } -function normalizeToolParams( - toolId: string, - params: Record, - context: ToolExecutionContext -): Record { - if (toolId !== FUNCTION_EXECUTE_TOOL_ID || !context.copilotToolExecution) { - return params - } - - const rawTimeoutSeconds = - params.timeout === undefined || params.timeout === null - ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS - : Number(params.timeout) - const timeoutSeconds = - Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 - ? rawTimeoutSeconds - : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS - - return { - ...params, - timeout: Math.min( - Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), - DEFAULT_EXECUTION_TIMEOUT_MS - ), - } -} - async function executeToolBatch( toolCalls: ToolCallDescriptor[], context: ToolExecutionContext @@ -138,11 +109,27 @@ async function executeToolBatch( } function buildAppToolParams( + toolId: string, params: Record, context: ToolExecutionContext ): Record { const result = { ...params } + if (toolId === FUNCTION_EXECUTE_TOOL_ID && context.copilotToolExecution) { + const rawTimeoutSeconds = + result.timeout === undefined || result.timeout === null + ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + : Number(result.timeout) + const timeoutSeconds = + Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 + ? rawTimeoutSeconds + : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + result.timeout = Math.min( + Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), + DEFAULT_EXECUTION_TIMEOUT_MS + ) + } + if (result.credentialId && !result.credential && !result.oauthCredential) { result.credential = result.credentialId } diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 7b5c9086716..999dea91b28 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { CheckDeploymentStatus, CompleteScheduledTask, - Cp as CpTool, CreateWorkflow, CreateWorkspaceMcpServer, DeleteWorkflow, @@ -34,17 +33,16 @@ import { ManageScheduledTask, ManageSkill, MaterializeFile, - Mkdir as MkdirTool, - Mv as MvTool, + MoveWorkflow, OauthGetAuthLink, OauthRequestAccess, OpenResource, PromoteToLive, Read as ReadTool, Redeploy, + RenameWorkflow, RestoreResource, RunBlock, - RunCode, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, @@ -91,9 +89,7 @@ import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/han 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' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' -import { executeVfsCp, executeVfsMkdir, executeVfsMv } from '../tools/handlers/vfs-mutate' import { executeCreateWorkflow, executeDeleteWorkflow, @@ -148,12 +144,9 @@ function buildHandlerMap(): Record { [CreateWorkflow.id]: h(executeCreateWorkflow), [DeleteWorkflow.id]: h(executeDeleteWorkflow), + [RenameWorkflow.id]: h(executeRenameWorkflow), + [MoveWorkflow.id]: h(executeMoveWorkflow), [ManageFolder.id]: h(executeManageFolder), - // rename_workflow / move_workflow were removed from the mothership catalog - // in favor of mv; the executors stay registered under literal names so - // in-flight checkpoints still resume. Delete after the mv release soaks. - rename_workflow: h(executeRenameWorkflow), - move_workflow: h(executeMoveWorkflow), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), @@ -185,9 +178,6 @@ function buildHandlerMap(): Record { [GrepTool.id]: h(executeVfsGrep), [GlobTool.id]: h(executeVfsGlob), [ReadTool.id]: h(executeVfsRead), - [MvTool.id]: h(executeVfsMv), - [CpTool.id]: h(executeVfsCp), - [MkdirTool.id]: h(executeVfsMkdir), [ManageCustomTool.id]: h(executeManageCustomTool), [ManageMcpTool.id]: h(executeManageMcpTool), @@ -201,7 +191,6 @@ function buildHandlerMap(): Record { [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), - [RunCode.id]: h(executeRunCode), ...buildServerToolHandlers(), } diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts index d79f294b71f..627927df4cf 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts @@ -12,7 +12,9 @@ describe('isToolHiddenInUi', () => { expect(isToolHiddenInUi('load_agent_skill')).toBe(true) }) - it('does not hide ordinary tools or undefined', () => { + it('does not hide user skill loads, ordinary tools, or undefined', () => { + // load_user_skill renders like the old per-skill loaders so the load is visible. + expect(isToolHiddenInUi('load_user_skill')).toBe(false) expect(isToolHiddenInUi('read')).toBe(false) expect(isToolHiddenInUi(undefined)).toBe(false) }) diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index dbe06363d7a..5154ad2db1d 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,13 +1,7 @@ // load_agent_skill is retained for historical persisted messages; it is no -// longer emitted now that internal skills autoload. -// search_integration_tools is gateway plumbing: the discovery step is not a -// user-meaningful action, only the resolved call_integration_tool row is. -const HIDDEN_TOOL_NAMES = new Set([ - 'load_agent_skill', - 'load_custom_tool', - 'load_integration_tool', - 'search_integration_tools', -]) +// longer emitted now that internal skills autoload. load_user_skill is NOT +// hidden — it renders like the old per-skill loaders so users see the skill load. +const HIDDEN_TOOL_NAMES = new Set(['load_agent_skill', 'load_custom_tool', 'load_integration_tool']) export function isToolHiddenInUi(toolName: string | undefined): boolean { return !!toolName && HIDDEN_TOOL_NAMES.has(toolName) 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..2fd6e54bcc6 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -25,11 +25,6 @@ describe('resolveToolDisplay', () => { }) it('formats read targets from workspace paths', () => { - expect(resolveToolDisplay(ReadTool.id, ClientToolCallState.executing)?.text).toBe( - 'Reading file' - ) - expect(resolveToolDisplay(ReadTool.id, ClientToolCallState.success)?.text).toBe('Read file') - expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { path: 'files/report.pdf', @@ -149,35 +144,12 @@ describe('resolveToolDisplay', () => { resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { path: 'components/blocks/unknown_block.json', })?.text - ).toBe('Read Unknown block') - }) - - it('humanizes internal VFS resource identifiers', () => { - expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { - path: 'environment/oauth-integrations.json', - })?.text - ).toBe('Reading OAuth integrations') - - expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'environment/oauth-integrations.json', - })?.text - ).toBe('Read OAuth integrations') - - expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'environment/api-key-integrations.json', - })?.text - ).toBe('Read API key integrations') + ).toBe('Read unknown_block') }) it('falls back to a humanized tool label for generic tools', () => { expect(resolveToolDisplay('deploy_api', ClientToolCallState.success)?.text).toBe( - 'Executed Deploy API' - ) - expect(resolveToolDisplay('oauth-integrations', ClientToolCallState.success)?.text).toBe( - 'Executed OAuth Integrations' + 'Executed Deploy Api' ) }) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 92bbeb6c572..f143602dcd2 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -6,7 +6,6 @@ import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display' import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** Respond tools are internal handoff tools shown with a friendly generic label. */ @@ -69,7 +68,7 @@ function readStringParam( } function formatReadingLabel(target: string | undefined, state: ClientToolCallState): string { - const suffix = ` ${target || 'file'}` + const suffix = target ? ` ${target}` : '' switch (state) { case ClientToolCallState.success: return `Read${suffix}` @@ -99,7 +98,7 @@ function describeReadTarget(path: string | undefined): string | undefined { const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] if (!resourceType) { - return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') + return stripExtension(segments[segments.length - 1]) } if (resourceType === 'file') { @@ -160,9 +159,9 @@ function humanizedFallback( toolName: string, state: ClientToolCallState ): ClientToolDisplay | undefined { - const titleCaseName = humanizeToolName(toolName) + const titleCaseName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) if (state === ClientToolCallState.error) { - const lowerCaseName = humanizeDisplayIdentifier(toolName, 'sentence') + const lowerCaseName = toolName.replace(/_/g, ' ').toLowerCase() return { text: `Attempted to ${lowerCaseName}`, icon: Loader } } const stateVerb = diff --git a/apps/sim/lib/copilot/tools/descriptions.test.ts b/apps/sim/lib/copilot/tools/descriptions.test.ts index ef5e3a3871b..6bb7d11d037 100644 --- a/apps/sim/lib/copilot/tools/descriptions.test.ts +++ b/apps/sim/lib/copilot/tools/descriptions.test.ts @@ -30,22 +30,6 @@ describe('getCopilotToolDescription', () => { ).toBe('Search for brands by company name API key is hosted by Sim.') }) - it.concurrent('does not claim unconditional hosting for a conditional hosted tool', () => { - expect( - getCopilotToolDescription( - { - id: 'image_generate', - name: 'Image Generate', - description: 'Generate an image', - hosting: { apiKeyParam: 'apiKey', enabled: () => true } as never, - }, - { isHosted: true } - ) - ).toBe( - 'Generate an image API key is hosted by Sim when hosted-key support applies to the selected configuration.' - ) - }) - it.concurrent('uses the fallback name when no description exists', () => { expect( getCopilotToolDescription( diff --git a/apps/sim/lib/copilot/tools/descriptions.ts b/apps/sim/lib/copilot/tools/descriptions.ts index 0defd9013c3..c315884a572 100644 --- a/apps/sim/lib/copilot/tools/descriptions.ts +++ b/apps/sim/lib/copilot/tools/descriptions.ts @@ -1,8 +1,6 @@ import type { ToolConfig } from '@/tools/types' const HOSTED_API_KEY_NOTE = 'API key is hosted by Sim.' -const CONDITIONAL_HOSTED_API_KEY_NOTE = - 'API key is hosted by Sim when hosted-key support applies to the selected configuration.' const EMAIL_TAGLINE_NOTE = 'Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.' const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send']) @@ -18,13 +16,8 @@ export function getCopilotToolDescription( const baseDescription = tool.description || tool.name || options?.fallbackName || '' const notes: string[] = [] - if ( - options?.isHosted && - tool.hosting && - !baseDescription.includes(HOSTED_API_KEY_NOTE) && - !baseDescription.includes(CONDITIONAL_HOSTED_API_KEY_NOTE) - ) { - notes.push(tool.hosting.enabled ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE) + if (options?.isHosted && tool.hosting && !baseDescription.includes(HOSTED_API_KEY_NOTE)) { + notes.push(HOSTED_API_KEY_NOTE) } if ( diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts index 2f5d592269e..7391a829ead 100644 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ b/apps/sim/lib/copilot/tools/handlers/access.ts @@ -1,6 +1,6 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' type WorkflowRecord = NonNullable>> @@ -47,23 +47,22 @@ export async function ensureWorkspaceAccess( workspaceId: string, userId: string, level: 'read' | 'write' | 'admin' = 'read' -): Promise { +): Promise { const access = await checkWorkspaceAccess(workspaceId, userId) if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } - if (level === 'read') return access + if (level === 'read') return if (level === 'admin') { if (!access.canAdmin) { throw new Error('Admin access required for this workspace') } - return access + return } if (!access.canWrite) { throw new Error('Write or admin access required for this workspace') } - return access } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 02d4a83c70b..58cb5c880ea 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -13,6 +13,7 @@ import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' import { applyDescriptionOverrides, generateToolInputSchema, + getMeaningfulWorkflowDescription, sanitizeToolName, } from '@/lib/mcp/workflow-tool-schema' import { @@ -611,7 +612,9 @@ export async function executeDeployMcp( params.toolName || workflowRecord.name || `workflow_${workflowId}` ) const toolDescription = - params.toolDescription?.trim() || `Execute ${workflowRecord.name} workflow` + params.toolDescription?.trim() || + getMeaningfulWorkflowDescription(workflowRecord.description, workflowRecord.name) || + `Execute ${workflowRecord.name} workflow` /** * Parameter names/types come from the workflow's deployed input trigger; this tool only sets * per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts index 0a4763bb39a..465da57bc33 100644 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts @@ -34,11 +34,10 @@ export async function executeListIntegrationTools( success: true, output: { integration: service, - note: 'Read the entry\'s "path" verbatim for exact params, then load_integration_tool({tool_ids: [""]}) and call the tool by that exact id.', + note: 'Call load_integration_tool({tool_ids: [""]}) with the exact id before invoking an operation.', tools: matches.map((tool) => ({ id: tool.toolId, operation: tool.operation, - path: `components/integrations/${tool.service}/${tool.operation}.json`, name: tool.config.name, description: tool.config.description, })), diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..17d62baa67d 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -34,14 +34,7 @@ export function executeManageCredential( if (!result.success) { return { success: false, error: result.error || 'Failed to rename credential' } } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, - } + return { success: true, output: { credentialId, displayName } } } case 'delete': { const ids: string[] = 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 6671ddc33ec..98612386531 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -59,17 +59,6 @@ vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() }) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file' -import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName } from '@/lib/workflows/utils' -import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' - -const fetchWorkspaceFileBufferMock = vi.mocked(fetchWorkspaceFileBuffer) -const parseWorkflowJsonMock = vi.mocked(parseWorkflowJson) -const saveWorkflowToNormalizedTablesMock = vi.mocked(saveWorkflowToNormalizedTables) -const deduplicateWorkflowNameMock = vi.mocked(deduplicateWorkflowName) -const extractWorkflowMetadataMock = vi.mocked(extractWorkflowMetadata) const context = { chatId: 'chat-1', @@ -134,45 +123,6 @@ describe('executeMaterializeFile - unsupported operation', () => { }) }) -describe('executeMaterializeFile - workflow import', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockFindUpload.mockResolvedValue({ - ...mothershipRow, - originalName: 'workflow.json', - displayName: 'workflow.json', - contentType: 'application/json', - }) - fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('{"metadata":{}}')) - parseWorkflowJsonMock.mockReturnValue({ - data: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: [] }, - errors: [], - }) - extractWorkflowMetadataMock.mockReturnValue({ - name: 'Imported Workflow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - }) - deduplicateWorkflowNameMock.mockResolvedValue('Imported Workflow') - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - }) - - it('does not persist the uploaded workflow description', async () => { - const result = await executeMaterializeFile( - { fileNames: ['workflow.json'], operation: 'import' }, - context - ) - - expect(result.success).toBe(true) - const insertedWorkflow = dbChainMockFns.values.mock.calls[0]?.[0] as Record - expect(insertedWorkflow).toMatchObject({ name: 'Imported Workflow' }) - expect(insertedWorkflow).not.toHaveProperty('description') - expect(JSON.stringify(dbChainMockFns.values.mock.calls)).not.toContain( - 'PRIVATE WORKFLOW DESCRIPTION' - ) - }) -}) - describe('executeMaterializeFile - save storage transition', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 407e41b58ab..6aa9d0e8a87 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -83,10 +83,7 @@ async function executeSave( .update(workspaceFiles) .set({ context: 'workspace', - // A workspace file has no birth chat or message — clear both provenance - // fields so the row reads as workspace-owned, not stale chat-owned. chatId: null, - messageId: null, originalName: row.displayName ?? row.originalName, size: verifiedSize, }) @@ -177,7 +174,7 @@ async function executeImport( } } - const { name: rawName } = extractWorkflowMetadata(parsed) + const { name: rawName, description: workflowDescription } = extractWorkflowMetadata(parsed) const workflowId = generateId() const now = new Date() @@ -189,6 +186,7 @@ async function executeImport( workspaceId, folderId: null, name: dedupedName, + description: workflowDescription, lastSynced: now, createdAt: now, updatedAt: now, diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts deleted file mode 100644 index 30870b9d636..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnsureWorkspaceAccess, mockGetCredentialActorContext } = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, -})) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { providerId: 'google-email', name: 'Gmail' }, - { providerId: 'slack', name: 'Slack' }, - { providerId: 'trello', name: 'Trello' }, - { providerId: 'shopify', name: 'Shopify' }, - ]), -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' - -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, - chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } -} - -describe('executeOAuthGetAuthLink', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - }) - - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) - }) - - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') - }) - - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) - - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index e3b55c36bae..2dcaabe02a7 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -2,44 +2,32 @@ import { toError } from '@sim/utils/errors' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' import { getAllOAuthServices } from '@/lib/oauth/utils' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' export async function executeOAuthGetAuthLink( rawParams: Record, context: ExecutionContext ): Promise { const providerName = String(rawParams.providerName || rawParams.provider_name || '') - const rawCredentialId = rawParams.credentialId || rawParams.credential_id - const credentialId = rawCredentialId ? String(rawCredentialId) : undefined const baseUrl = getBaseUrl() try { if (!context.workspaceId || !context.userId) { throw new Error('workspaceId and userId are required to generate an OAuth link') } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') const result = await generateOAuthLink( context.workspaceId, context.workflowId, context.chatId, providerName, - baseUrl, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined + baseUrl ) - const action = credentialId ? 'reconnect' : 'connect' return { success: true, output: { - message: credentialId - ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` - : `Authorization URL generated for ${result.serviceName}.`, + message: `Authorization URL generated for ${result.serviceName}.`, oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`, provider: result.serviceName, providerId: result.providerId, }, @@ -84,20 +72,13 @@ export async function executeOAuthRequestAccess( * calls Better Auth, so the draft's TTL starts at click and the signed `state` * cookie is planted in the user's browser and the OAuth callback's state check * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. */ async function generateOAuthLink( workspaceId: string | undefined, workflowId: string | undefined, chatId: string | undefined, providerName: string, - baseUrl: string, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } + baseUrl: string ): Promise<{ url: string; providerId: string; serviceName: string }> { if (!workspaceId) { throw new Error('workspaceId is required to generate an OAuth link') @@ -124,39 +105,6 @@ async function generateOAuthLink( } const { providerId, name: serviceName } = matched - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - const callbackURL = workflowId && workspaceId ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` @@ -196,9 +144,6 @@ async function generateOAuthLink( authorizeUrl.searchParams.set('providerId', providerId) authorizeUrl.searchParams.set('workspaceId', workspaceId) authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } return { url: authorizeUrl.toString(), providerId, serviceName } } diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 01544f6587c..19eae663ba8 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -33,6 +33,7 @@ export interface CreateWorkflowParams { name?: string workspaceId?: string folderId?: string + description?: string } export interface CreateFolderParams { @@ -246,6 +247,12 @@ export interface RenameWorkflowParams { name: string } +export interface UpdateWorkflowParams { + workflowId: string + name?: string + description?: string +} + export interface DeleteWorkflowParams { workflowIds: string[] } diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts deleted file mode 100644 index e27babc4512..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/run-code.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' - -/** - * Compute-only variant of function_execute 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 - * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a - * leaked arg could not write anything. - */ -export async function executeRunCode( - params: Record, - context: ToolExecutionContext -): Promise { - if ('outputs' in params) { - return { - success: false, - error: - 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', - } - } - if ('outputTable' in params) { - return { - success: false, - error: - 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', - } - } - return executeFunctionExecute(params, context) -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts deleted file mode 100644 index 30847fdcc07..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ /dev/null @@ -1,509 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - ensureWorkspaceAccess: vi.fn(), - ensureWorkflowAccess: vi.fn(), - getDefaultWorkspaceId: vi.fn(), - assertFolderMutable: vi.fn(), - assertWorkflowMutable: vi.fn(), - getWorkspaceFileByName: vi.fn(), - findWorkspaceFileFolderIdByPath: vi.fn(), - ensureWorkspaceFileFolderPath: vi.fn(), - performMoveRenameWorkspaceFile: vi.fn(), - performUpdateWorkspaceFileFolder: vi.fn(), - performCreateFolder: vi.fn(), - performUpdateFolder: vi.fn(), - performUpdateWorkflow: vi.fn(), - duplicateWorkflow: vi.fn(), - listFolders: vi.fn(), - verifyFolderWorkspace: vi.fn(), - listTables: vi.fn(), - renameTable: vi.fn(), - getKnowledgeBases: vi.fn(), - updateKnowledgeBase: vi.fn(), - checkKnowledgeBaseWriteAccess: vi.fn(), - workflowRows: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => Promise.resolve(mocks.workflowRows()), - }), - }), - }, - workflow: { id: 'id', name: 'name', folderId: 'folderId', workspaceId: 'workspaceId' }, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - assertFolderMutable: mocks.assertFolderMutable, - assertWorkflowMutable: mocks.assertWorkflowMutable, -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, - ensureWorkflowAccess: mocks.ensureWorkflowAccess, - getDefaultWorkspaceId: mocks.getDefaultWorkspaceId, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFileByName: mocks.getWorkspaceFileByName, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, - normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, -})) - -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateFolder: mocks.performCreateFolder, - performUpdateFolder: mocks.performUpdateFolder, - performUpdateWorkflow: mocks.performUpdateWorkflow, -})) - -vi.mock('@/lib/workflows/persistence/duplicate', () => ({ - duplicateWorkflow: mocks.duplicateWorkflow, -})) - -vi.mock('@/lib/workflows/utils', () => ({ - listFolders: mocks.listFolders, - verifyFolderWorkspace: mocks.verifyFolderWorkspace, -})) - -vi.mock('@/lib/table/service', () => ({ - listTables: mocks.listTables, - renameTable: mocks.renameTable, -})) - -vi.mock('@/lib/knowledge/service', () => ({ - getKnowledgeBases: mocks.getKnowledgeBases, - updateKnowledgeBase: mocks.updateKnowledgeBase, -})) - -vi.mock('@/app/api/knowledge/utils', () => ({ - checkKnowledgeBaseWriteAccess: mocks.checkKnowledgeBaseWriteAccess, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' - -const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext - -describe('vfs mv/cp', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) - mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) - mocks.assertFolderMutable.mockResolvedValue(undefined) - mocks.assertWorkflowMutable.mockResolvedValue(undefined) - mocks.verifyFolderWorkspace.mockResolvedValue(true) - mocks.listFolders.mockResolvedValue([]) - mocks.workflowRows.mockReturnValue([]) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) - mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') - }) - - describe('category rules', () => { - it('rejects cross-category moves', async () => { - const result = await executeVfsMv( - { sources: ['files/report.pdf'], destination: 'workflows/report' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('across categories') - }) - - it('rejects uploads with a materialize_file 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') - }) - - it('rejects read-only categories', async () => { - const result = await executeVfsMv( - { sources: ['components/blocks/gmail.json'], destination: 'components/blocks/g.json' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('not a movable resource') - }) - - it('aborts before mutating when the request was cancelled', async () => { - const abortedContext = { - userId: 'user-1', - workspaceId: 'ws-1', - abortSignal: { aborted: true }, - } as unknown as ExecutionContext - const result = await executeVfsMv( - { sources: ['files/a.md'], destination: 'files/b.md' }, - abortedContext - ) - expect(result.success).toBe(false) - expect(result.error).toContain('aborted') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() - }) - }) - - describe('files', () => { - it('moves and renames a file in one call, auto-creating destination folders', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, - file: { id: 'file-1', name: 'final.md' }, - }) - - const result = await executeVfsMv( - { sources: ['files/draft.md'], destination: 'files/Reports/2026/final.md' }, - context - ) - - expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { - folderId: null, - }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - fileId: 'file-1', - targetFolderId: 'ensured-folder', - newName: 'final.md', - }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], - }) - }) - - it('moves into an existing folder keeping the name without creating anything', async () => { - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, - file: { id: 'file-1', name: 'a.png' }, - }) - - const result = await executeVfsMv( - { sources: ['files/a.png'], destination: 'files/Images' }, - context - ) - - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) - ) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) - }) - - it('requires a folder destination for multiple sources', async () => { - const result = await executeVfsMv( - { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('must be a folder') - }) - - it('resolves sources at their exact path only — no cross-folder name fallback', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) - - const result = await executeVfsMv( - { sources: ['files/report.pdf'], destination: 'files/Archive/' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Not found') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - }) - - it('rejects copying workspace files — cp is workflows-only', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'template.md' }) - - const result = await executeVfsCp( - { sources: ['files/template.md'], destination: 'files/Reports/january.md' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('cp only duplicates workflows') - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - }) - - it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { - mocks.findWorkspaceFileFolderIdByPath - .mockResolvedValueOnce(null) // destination is not an existing folder - .mockResolvedValueOnce('folder-src') // source resolves as folder - mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ - success: true, - folder: { name: 'Reports 2025' }, - }) - - const result = await executeVfsMv( - { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, - context - ) - - expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - folderId: 'folder-src', - userId: 'user-1', - name: 'Reports 2025', - parentId: 'ensured-folder', - }) - expect(result.success).toBe(true) - }) - - it('rejects reserved alias backing paths', async () => { - const result = await executeVfsMv( - { sources: ['files/.plans/wf_1/launch.md'], destination: 'files/launch.md' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('Reserved system paths') - }) - }) - - describe('workflows', () => { - it('renames a workflow at root', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Old Name', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) - - const result = await executeVfsMv( - { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, - context - ) - - expect(mocks.assertWorkflowMutable).toHaveBeenCalledWith('wf-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: 'New Name', folderId: null }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) - }) - - it('moves a workflow into an existing folder keeping its name', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Archive', parentId: null }, - ]) - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'My Workflow', folderId: null }]) - mocks.performUpdateWorkflow.mockResolvedValue({ success: true }) - - const result = await executeVfsMv( - { sources: ['workflows/My%20Workflow'], destination: 'workflows/Archive' }, - context - ) - - expect(mocks.assertFolderMutable).toHaveBeenCalledWith('fold-1') - expect(mocks.performUpdateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', name: undefined, folderId: 'fold-1' }) - ) - expect(result.success).toBe(true) - }) - - it('surfaces locked-workflow rejections per item', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Locked One', folderId: null }]) - mocks.assertWorkflowMutable.mockRejectedValue(new Error('Workflow is locked')) - - const result = await executeVfsMv( - { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('locked') - }) - - it('duplicates a workflow with cp (locked source allowed)', async () => { - mocks.workflowRows.mockReturnValue([{ id: 'wf-1', name: 'Template', folderId: null }]) - mocks.duplicateWorkflow.mockResolvedValue({ id: 'wf-2', name: 'My Copy' }) - - const result = await executeVfsCp( - { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, - context - ) - - expect(mocks.assertWorkflowMutable).not.toHaveBeenCalled() - expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - sourceWorkflowId: 'wf-1', - workspaceId: 'ws-1', - folderId: null, - name: 'My Copy', - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) - }) - - it('rejects copying workflow folders', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Projects', parentId: null }, - ]) - const result = await executeVfsCp( - { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be copied') - }) - - it('moves and renames a workflow folder', async () => { - mocks.listFolders.mockResolvedValue([ - { folderId: 'fold-1', folderName: 'Q1', parentId: null }, - { folderId: 'fold-2', folderName: 'Archive', parentId: null }, - ]) - mocks.performUpdateFolder.mockResolvedValue({ success: true }) - - const result = await executeVfsMv( - { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, - context - ) - - expect(mocks.performUpdateFolder).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'fold-1', name: 'Q1 2026', parentId: 'fold-2' }) - ) - expect(result.success).toBe(true) - }) - }) - - describe('mkdir', () => { - it('creates a nested file folder chain', async () => { - const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], - }) - }) - - it('creates a workflow folder via performCreateFolder', async () => { - mocks.listFolders.mockResolvedValue([]) - mocks.performCreateFolder.mockResolvedValue({ success: true, folder: { id: 'fold-new' } }) - - const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) - - expect(mocks.performCreateFolder).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - name: 'Archive', - parentId: undefined, - }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ to: 'workflows/Archive', kind: 'workflow_folder', id: 'fold-new' }], - }) - }) - - it('rejects flat namespaces and reserved paths', async () => { - const result = await executeVfsMkdir({ paths: ['tables/CRM', 'files/.plans/wf_1'] }, context) - expect(result.success).toBe(false) - expect(result.output).toMatchObject({ - results: [ - { from: 'tables/CRM', error: expect.stringContaining('flat namespace') }, - { from: 'files/.plans/wf_1', error: expect.stringContaining('Reserved') }, - ], - }) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - }) - - it('rejects creation inside a locked workflow folder', async () => { - mocks.listFolders.mockResolvedValue([]) - mocks.assertFolderMutable.mockRejectedValue(new Error('Folder is locked')) - - const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('locked') - expect(mocks.performCreateFolder).not.toHaveBeenCalled() - }) - }) - - describe('tables and knowledge bases (flat namespaces)', () => { - it('renames a table', async () => { - mocks.listTables.mockResolvedValue([{ id: 'tbl-1', name: 'Leads' }]) - mocks.renameTable.mockResolvedValue({ id: 'tbl-1', name: 'Customers' }) - - const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/Customers' }, - context - ) - - expect(mocks.renameTable).toHaveBeenCalledWith('tbl-1', 'Customers', expect.any(String)) - 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 () => { - const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/CRM/Leads' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('flat namespace') - expect(mocks.renameTable).not.toHaveBeenCalled() - }) - - it('rejects copying tables', async () => { - const result = await executeVfsCp( - { sources: ['tables/Leads'], destination: 'tables/Leads Copy' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be copied') - }) - - it('renames a knowledge base after a write-access check', async () => { - mocks.getKnowledgeBases.mockResolvedValue([{ id: 'kb-1', name: 'Docs' }]) - mocks.checkKnowledgeBaseWriteAccess.mockResolvedValue({ hasAccess: true }) - mocks.updateKnowledgeBase.mockResolvedValue({ id: 'kb-1', name: 'Product Docs' }) - - const result = await executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, - context - ) - - expect(mocks.checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-1', 'user-1') - expect(mocks.updateKnowledgeBase).toHaveBeenCalledWith( - 'kb-1', - { name: 'Product Docs' }, - expect.any(String) - ) - expect(result.success).toBe(true) - }) - - it('rejects the reserved knowledgebases/connectors name', async () => { - const result = await executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('reserved') - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts deleted file mode 100644 index 44682d3e615..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { db, workflow as workflowTable } from '@sim/db' -import { createLogger } from '@sim/logger' -import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { - ensureWorkflowAccess, - ensureWorkspaceAccess, - getDefaultWorkspaceId, -} from '@/lib/copilot/tools/handlers/access' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { - buildVfsFolderPathMap, - canonicalWorkflowVfsDir, - decodeVfsPathSegments, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' -import { generateRequestId } from '@/lib/core/utils/request' -import { getKnowledgeBases, updateKnowledgeBase } from '@/lib/knowledge/service' -import { listTables, renameTable } from '@/lib/table/service' -import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - performCreateFolder, - performUpdateFolder, - performUpdateWorkflow, -} from '@/lib/workflows/orchestration' -import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' -import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' -import { - performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' - -const logger = createLogger('VfsMutateTools') - -type MutateVerb = 'mv' | 'cp' - -type MutateCategory = 'files' | 'workflows' | 'tables' | 'knowledgebases' - -const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'knowledgebases']) - -const CATEGORY_REJECTIONS: Record = { - uploads: - 'uploads/ files are chat-scoped and immutable. Use materialize_file to promote one into files/ first.', - 'recently-deleted': - 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', -} - -interface VfsMutateOutcome { - from: string - to?: string - kind: 'file' | 'file_folder' | 'workflow' | 'workflow_folder' | 'table' | 'knowledge_base' - id?: string - error?: string -} - -/** Top-level VFS segment of a raw (possibly encoded) path. */ -function topLevelSegment(path: string): string { - return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' -} - -function classifyCategory(path: string): { category: MutateCategory } | { error: string } { - const top = topLevelSegment(path) - if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } - const rejection = CATEGORY_REJECTIONS[top] - if (rejection) return { error: rejection } - return { - error: `"${path}" is not a movable resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, - } -} - -function normalizeSources(raw: unknown): string[] { - if (typeof raw === 'string') return raw.trim() ? [raw.trim()] : [] - if (!Array.isArray(raw)) return [] - return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) -} - -function hasTrailingSlash(path: string): boolean { - return /\/\s*$/.test(path) -} - -function assertMutationNotAborted(context: ExecutionContext): void { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before the mutation could be applied.') - } -} - -function buildResult(verb: MutateVerb | 'mkdir', outcomes: VfsMutateOutcome[]): ToolCallResult { - const failed = outcomes.filter((o) => o.error) - if (failed.length === outcomes.length) { - return { - success: false, - error: failed[0]?.error || `${verb} failed`, - output: { results: outcomes }, - } - } - return { success: true, output: { results: outcomes } } -} - -export async function executeVfsMv( - params: Record, - context: ExecutionContext -): Promise { - return executeVfsMutate('mv', params, context) -} - -export async function executeVfsCp( - params: Record, - context: ExecutionContext -): Promise { - return executeVfsMutate('cp', params, context) -} - -/** - * mkdir -p over the VFS: creates each folder path (missing parents included) - * under files/ or workflows/. Existing folders are not an error. - */ -export async function executeVfsMkdir( - params: Record, - context: ExecutionContext -): Promise { - try { - const paths = normalizeSources(params.paths) - if (paths.length === 0) { - return { success: false, error: 'paths is required (an array of folder VFS paths)' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - assertMutationNotAborted(context) - - let ensureWorkflowFolder: ((segments: string[]) => Promise) | undefined - - 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' - - 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.`) - outcomes.push({ from: path, kind, error: rejection }) - continue - } - if (segments.length === 0) { - outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) - continue - } - if (top === 'files' && isWorkflowAliasBackingPath(path)) { - outcomes.push({ from: path, kind, error: `Reserved system path: ${path}` }) - continue - } - - try { - assertMutationNotAborted(context) - let folderId: string | null - if (top === 'files') { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }) - } else { - ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( - workspaceId, - context.userId, - await loadWorkflowFolderIndex(workspaceId) - ) - folderId = await ensureWorkflowFolder(segments) - } - outcomes.push({ - from: path, - to: `${top}/${encodeVfsPathSegments(segments)}`, - kind, - id: folderId ?? undefined, - }) - } catch (error) { - outcomes.push({ from: path, kind, error: toError(error).message }) - } - } - - return buildResult('mkdir', outcomes) - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -async function executeVfsMutate( - verb: MutateVerb, - params: Record, - context: ExecutionContext -): Promise { - try { - const sources = normalizeSources(params.sources) - const destination = typeof params.destination === 'string' ? params.destination.trim() : '' - if (sources.length === 0) { - return { success: false, error: 'sources is required (an array of canonical VFS paths)' } - } - if (!destination) { - return { success: false, error: 'destination is required' } - } - - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - assertMutationNotAborted(context) - - const classified = classifyCategory(sources[0]) - if ('error' in classified) return { success: false, error: classified.error } - const { category } = classified - - for (const source of sources.slice(1)) { - const other = classifyCategory(source) - if ('error' in other) return { success: false, error: other.error } - if (other.category !== category) { - return { - success: false, - error: `All sources must share one category; got ${category}/ and ${other.category}/.`, - } - } - } - - const destTop = topLevelSegment(destination) - if (destTop !== category) { - return { - success: false, - error: `Cannot ${verb} across categories: ${category}/ sources cannot target "${destination}". Resources stay within their category.`, - } - } - - switch (category) { - case 'files': - return await mutateWorkspaceFiles(verb, sources, destination, context, workspaceId) - case 'workflows': - return await mutateWorkflows(verb, sources, destination, context, workspaceId) - default: - return await renameFlatResource(verb, category, sources, destination, context, workspaceId) - } - } catch (error) { - return { success: false, error: toError(error).message } - } -} - -interface DestinationPlan { - /** True when sources move INTO the destination folder keeping their names. */ - dirMode: boolean - /** Decoded display-name segments of the destination folder. */ - folderSegments: string[] - /** New leaf name; set only when `dirMode` is false. */ - leafName?: string - /** - * Resolve the destination folder id, creating missing folders on first call. - * Deferred and memoized so nothing is created until a source is confirmed - * valid — a fully-failed mv/cp must not leave folders behind. - */ - ensureFolderId: () => Promise -} - -/** - * Shared destination interpretation for every category with folders: an - * existing folder (or a trailing "/") means move/copy INTO it keeping names; - * otherwise the last segment is the new name and the preceding segments are - * the target folder. Folder creation is deferred to `ensureFolderId`. - */ -async function planDestination(args: { - destination: string - sourceCount: number - lookupFolder: (segments: string[]) => Promise - ensureFolderPath: (segments: string[]) => Promise -}): Promise { - const rest = decodeVfsPathSegments(args.destination).slice(1) - const plan = ( - dirMode: boolean, - folderSegments: string[], - leafName?: string, - knownFolderId?: string | null - ): DestinationPlan => { - let memo: Promise | undefined - return { - dirMode, - folderSegments, - leafName, - ensureFolderId: () => - (memo ??= - knownFolderId !== undefined - ? Promise.resolve(knownFolderId) - : folderSegments.length > 0 - ? args.ensureFolderPath(folderSegments) - : Promise.resolve(null)), - } - } - - if (rest.length === 0) return plan(true, [], undefined, null) - if (hasTrailingSlash(args.destination)) return plan(true, rest) - const existing = await args.lookupFolder(rest) - if (existing) return plan(true, rest, undefined, existing) - if (args.sourceCount > 1) { - return { - error: `With multiple sources the destination must be a folder. "${args.destination}" does not exist — end it with "/" to create it.`, - } - } - return plan(false, rest.slice(0, -1), rest.at(-1) as string) -} - -/** - * Resolve a `files/...` source to the file at EXACTLY that path (folder- - * anchored). Deliberately not the lenient read-side resolver — on a - * destructive path a bare-name fallback could match a file in a different - * folder than the one named. - */ -async function resolveFileAtExactPath( - workspaceId: string, - segments: string[] -): Promise { - const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') - if (segments.length === 1) { - return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) - if (!folderId) return null - return getWorkspaceFileByName(workspaceId, fileName, { folderId }) -} - -async function mutateWorkspaceFiles( - verb: MutateVerb, - sources: string[], - destination: string, - context: ExecutionContext, - workspaceId: string -): Promise { - if (verb === 'cp') { - return { - success: false, - error: 'Workspace files cannot be copied — cp only duplicates workflows.', - } - } - for (const path of [...sources, destination]) { - if (isWorkflowAliasBackingPath(path)) { - return { - success: false, - error: `Reserved system paths cannot be moved or renamed: ${path}`, - } - } - } - - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => - ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }), - }) - if ('error' in dest) return { success: false, error: dest.error } - - // Resolve every source read-only before mutating anything, so a fully - // invalid call cannot create destination folders as a side effect. - type SourceRef = - | { source: string; file: WorkspaceFileRecord } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a file or folder under files/' }) - continue - } - const file = await resolveFileAtExactPath(workspaceId, segments) - if (file) { - refs.push({ source, file }) - continue - } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) - } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'file', error: ref.error }) - continue - } - - if ('file' in ref) { - assertMutationNotAborted(context) - const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) - const targetFolderId = await dest.ensureFolderId() - const result = await performMoveRenameWorkspaceFile({ - workspaceId, - userId: context.userId, - fileId: ref.file.id, - targetFolderId, - newName: targetName, - }) - outcomes.push( - result.success && result.file - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - } - : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } - ) - continue - } - - assertMutationNotAborted(context) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'file_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId: ref.folderId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push( - result.success && result.folder - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - } - : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } - ) - } - - return buildResult(verb, outcomes) -} - -interface WorkflowFolderIndex { - folderPathById: Map - folderIdByPath: Map -} - -async function loadWorkflowFolderIndex(workspaceId: string): Promise { - const folderPathById = buildVfsFolderPathMap(await listFolders(workspaceId)) - const folderIdByPath = new Map() - for (const [id, path] of folderPathById.entries()) folderIdByPath.set(path, id) - return { folderPathById, folderIdByPath } -} - -/** - * mkdir -p for workflow folders: resolves each segment against the index, - * creating missing ones (locked parents rejected) and keeping the index maps - * current so later paths in the same call see the new folders. - */ -function makeWorkflowFolderEnsurer( - workspaceId: string, - userId: string, - index: WorkflowFolderIndex -): (segments: string[]) => Promise { - return async (segments) => { - let parentId: string | null = null - let pathSoFar = '' - for (const segment of segments) { - pathSoFar = pathSoFar - ? `${pathSoFar}/${encodeVfsPathSegments([segment])}` - : encodeVfsPathSegments([segment]) - const existing = index.folderIdByPath.get(pathSoFar) - if (existing) { - parentId = existing - continue - } - await assertFolderMutable(parentId) - const created = await performCreateFolder({ - workspaceId, - userId, - name: segment, - parentId: parentId ?? undefined, - }) - if (!created.success || !created.folder) { - throw new Error(created.error || `Failed to create workflow folder "${segment}"`) - } - index.folderIdByPath.set(pathSoFar, created.folder.id) - index.folderPathById.set(created.folder.id, pathSoFar) - parentId = created.folder.id - } - return parentId - } -} - -async function mutateWorkflows( - verb: MutateVerb, - sources: string[], - destination: string, - context: ExecutionContext, - workspaceId: string -): Promise { - const index = await loadWorkflowFolderIndex(workspaceId) - const { folderPathById, folderIdByPath } = index - - const workflowRows = await db - .select({ - id: workflowTable.id, - name: workflowTable.name, - folderId: workflowTable.folderId, - }) - .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) - const workflowByPath = new Map() - for (const row of workflowRows) { - const dir = canonicalWorkflowVfsDir({ - name: row.name, - folderPath: row.folderId ? folderPathById.get(row.folderId) : null, - }) - if (!workflowByPath.has(dir)) workflowByPath.set(dir, row) - } - - const ensureWorkflowFolderPath = makeWorkflowFolderEnsurer(workspaceId, context.userId, index) - - const dest = await planDestination({ - destination, - sourceCount: sources.length, - lookupFolder: async (segments) => folderIdByPath.get(encodeVfsPathSegments(segments)) ?? null, - ensureFolderPath: ensureWorkflowFolderPath, - }) - if ('error' in dest) return { success: false, error: dest.error } - if (!dest.dirMode && (dest.leafName as string).length > 200) { - return { success: false, error: 'Workflow name must be 200 characters or less' } - } - - // Resolve every source against the in-memory maps before mutating anything. - type SourceRef = - | { source: string; workflow: (typeof workflowRows)[number] } - | { source: string; folderId: string } - | { source: string; error: string } - const refs: SourceRef[] = [] - for (const source of sources) { - const segments = decodeVfsPathSegments(source).slice(1) - if (segments.length === 0) { - refs.push({ source, error: 'Source must name a workflow or folder under workflows/' }) - continue - } - const encoded = encodeVfsPathSegments(segments) - const wf = workflowByPath.get(`workflows/${encoded}`) - if (wf) { - refs.push({ source, workflow: wf }) - continue - } - const folderId = folderIdByPath.get(encoded) - if (folderId) refs.push({ source, folderId }) - else refs.push({ source, error: `Not found: ${source}` }) - } - - const outcomes: VfsMutateOutcome[] = [] - for (const ref of refs) { - if ('error' in ref) { - outcomes.push({ from: ref.source, kind: 'workflow', error: ref.error }) - continue - } - - if ('workflow' in ref) { - const wf = ref.workflow - const targetName = dest.dirMode ? wf.name : (dest.leafName as string) - try { - assertMutationNotAborted(context) - if (verb === 'cp') { - const targetFolderId = await dest.ensureFolderId() - const duplicated = await duplicateWorkflow({ - sourceWorkflowId: wf.id, - userId: context.userId, - workspaceId, - folderId: targetFolderId, - name: targetName, - requestId: generateRequestId(), - }) - outcomes.push({ - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, duplicated.name])}`, - kind: 'workflow', - id: duplicated.id, - }) - } else { - await ensureWorkflowAccess(wf.id, context.userId, 'write') - await assertWorkflowMutable(wf.id) - const targetFolderId = await dest.ensureFolderId() - await assertFolderMutable(targetFolderId) - if (targetFolderId && !(await verifyFolderWorkspace(targetFolderId, workspaceId))) { - outcomes.push({ - from: ref.source, - kind: 'workflow', - error: 'Destination folder not found', - }) - continue - } - const result = await performUpdateWorkflow({ - workflowId: wf.id, - userId: context.userId, - workspaceId, - currentName: wf.name, - currentFolderId: wf.folderId, - name: dest.dirMode ? undefined : targetName, - folderId: targetFolderId, - }) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, targetName])}`, - kind: 'workflow', - id: wf.id, - } - : { - from: ref.source, - kind: 'workflow', - error: result.error || 'Failed to move workflow', - } - ) - } - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow', error: toError(error).message }) - } - continue - } - - if (verb === 'cp') { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Workflow folders cannot be copied.', - }) - continue - } - try { - assertMutationNotAborted(context) - await assertFolderMutable(ref.folderId) - const targetFolderId = await dest.ensureFolderId() - if (targetFolderId === ref.folderId) { - outcomes.push({ - from: ref.source, - kind: 'workflow_folder', - error: 'Cannot move a folder into itself', - }) - continue - } - await assertFolderMutable(targetFolderId) - const result = await performUpdateFolder({ - folderId: ref.folderId, - workspaceId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - const finalLeaf = dest.dirMode - ? (decodeVfsPathSegments(ref.source).slice(1).at(-1) ?? '') - : (dest.leafName as string) - outcomes.push( - result.success - ? { - from: ref.source, - to: `workflows/${encodeVfsPathSegments([...dest.folderSegments, finalLeaf])}`, - kind: 'workflow_folder', - id: ref.folderId, - } - : { - from: ref.source, - kind: 'workflow_folder', - error: result.error || 'Failed to move folder', - } - ) - } catch (error) { - outcomes.push({ from: ref.source, kind: 'workflow_folder', error: toError(error).message }) - } - } - - return buildResult(verb, outcomes) -} - -async function renameFlatResource( - verb: MutateVerb, - category: 'tables' | 'knowledgebases', - sources: string[], - destination: string, - context: ExecutionContext, - 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 sourceName = sourceSegments[0] - const newName = destSegments[0] - const canonicalSource = normalizeVfsSegment(sourceName) - - if (category === 'tables') { - const tables = await listTables(workspaceId) - const match = tables.find((t) => normalizeVfsSegment(t.name) === canonicalSource) - if (!match) { - return { success: false, error: `Table not found at ${sources[0]}` } - } - assertMutationNotAborted(context) - const renamed = await renameTable(match.id, newName, generateRequestId()) - return buildResult(verb, [ - { - from: sources[0], - to: `tables/${normalizeVfsSegment(renamed.name)}`, - kind, - id: match.id, - }, - ]) - } - - if (newName.toLowerCase() === 'connectors') { - return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } - } - const kbs = await getKnowledgeBases(context.userId, workspaceId) - const match = kbs.find((kb) => normalizeVfsSegment(kb.name) === canonicalSource) - if (!match) { - return { success: false, error: `Knowledge base not found at ${sources[0]}` } - } - const access = await checkKnowledgeBaseWriteAccess(match.id, context.userId) - if (!access.hasAccess) { - return { - success: false, - error: `Write access required to rename knowledge base "${match.name}"`, - } - } - assertMutationNotAborted(context) - await updateKnowledgeBase(match.id, { name: newName }, generateRequestId()) - logger.info('Renamed knowledge base via mv', { knowledgeBaseId: match.id, workspaceId }) - return buildResult(verb, [ - { from: sources[0], to: `knowledgebases/${normalizeVfsSegment(newName)}`, kind, id: match.id }, - ]) -} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index 1720cd1c668..b2cd2356ead 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -293,33 +293,6 @@ describe('executeCreateWorkflow billing attribution', () => { reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true }) }) - it('ignores legacy description input instead of persisting it', async () => { - performCreateWorkflowMock.mockResolvedValue({ - success: true, - workflow: { - id: 'created-workflow', - name: 'Created Workflow', - workspaceId: 'workspace-1', - folderId: null, - }, - }) - const legacyParams = { - name: 'Created Workflow', - workspaceId: 'workspace-1', - description: 'PRIVATE WORKFLOW DESCRIPTION', - } as Parameters[0] - - const result = await executeCreateWorkflow(legacyParams, executionContext) - - expect(result.success).toBe(true) - expect(performCreateWorkflowMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - name: 'Created Workflow', - folderId: null, - }) - }) - it('keeps same-workspace creation and subsequent execution on the immutable payer', async () => { const context: ExecutionContext = { ...executionContext, workflowId: '' } performCreateWorkflowMock.mockResolvedValue({ diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 65d6a84f60e..41b62b62099 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -365,6 +365,7 @@ import type { RunWorkflowUntilBlockParams, SetBlockEnabledParams, SetGlobalWorkflowVariablesParams, + UpdateWorkflowParams, VariableOperation, } from '../param-types' @@ -391,6 +392,11 @@ export async function executeCreateWorkflow( if (name.length > 200) { return { success: false, error: 'Workflow name must be 200 characters or less' } } + const description = typeof params?.description === 'string' ? params.description : null + if (description && description.length > 2000) { + return { success: false, error: 'Description must be 2000 characters or less' } + } + const workspaceId = params?.workspaceId || context.workspaceId || (await getDefaultWorkspaceId(context.userId)) const folderId = params?.folderId || null @@ -403,6 +409,7 @@ export async function executeCreateWorkflow( userId: context.userId, workspaceId, name, + description, folderId, }) if (!result.success || !result.workflow) { @@ -884,7 +891,8 @@ export async function executeGenerateApiKey( name: result.key.name, key: result.key.key, workspaceId, - message: `API key "${result.key.name}" created. You did NOT receive the key value — Sim reveals it to the user ONLY through the secure, copyable chip it renders where you place a {"type":"sim_key"} tag, so you MUST emit that tag now or the user can never see the key (it cannot be shown again). Never print, guess, or fabricate a value. The key authenticates calls to deployed workflow endpoints via the x-api-key header.`, + message: + 'API key created successfully. Copy this key now — it will not be shown again. Use this key in the x-api-key header when calling workflow API endpoints.', }, } } catch (error) { @@ -950,6 +958,64 @@ export async function executeRunFromBlock( } } +async function executeUpdateWorkflow( + params: UpdateWorkflowParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + + const updates: { name?: string; description?: string } = {} + + if (typeof params.name === 'string') { + const name = params.name.trim() + if (!name) return { success: false, error: 'name cannot be empty' } + if (name.length > 200) + return { success: false, error: 'Workflow name must be 200 characters or less' } + updates.name = name + } + + if (typeof params.description === 'string') { + if (params.description.length > 2000) { + return { success: false, error: 'Description must be 2000 characters or less' } + } + updates.description = params.description + } + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'At least one of name or description is required' } + } + + const current = await ensureWorkflowAccess(workflowId, context.userId, 'write') + if (!current.workspaceId) { + return { success: false, error: 'Workflow workspace is required' } + } + await assertWorkflowMutable(workflowId) + assertWorkflowMutationNotAborted(context) + const result = await performUpdateWorkflow({ + workflowId, + userId: context.userId, + workspaceId: current.workspaceId, + currentName: current.workflow.name, + currentFolderId: current.workflow.folderId, + ...updates, + }) + if (!result.success) { + return { success: false, error: result.error || 'Failed to update workflow' } + } + + return { + success: true, + output: { workflowId, ...updates }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + export async function executeSetBlockEnabled( params: SetBlockEnabledParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/server/files/delete-file.ts b/apps/sim/lib/copilot/tools/server/files/delete-file.ts index 335d2930e10..0f2934fd7b4 100644 --- a/apps/sim/lib/copilot/tools/server/files/delete-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/delete-file.ts @@ -25,10 +25,6 @@ interface DeleteFileArgs { interface DeleteFileResult { success: boolean message: string - data?: { - deleted: { id: string; name: string }[] - failed: string[] - } } export const deleteFileServerTool: BaseServerTool = { @@ -106,7 +102,6 @@ export const deleteFileServerTool: BaseServerTool 0, message: parts.join('. '), - data: { deleted: deletable, failed }, } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 9236438b1e7..109256e23e4 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { DeleteFileFolder } from '@/lib/copilot/generated/tool-catalog-v1' +import { + CreateFileFolder, + DeleteFileFolder, + ListFileFolders, + MoveFile, + MoveFileFolder, + RenameFileFolder, +} from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -8,9 +15,7 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isWorkflowAliasBackingPath } from '@/lib/copilot/vfs/workflow-aliases' import { - ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, listWorkspaceFileFolders, @@ -187,7 +192,7 @@ function folderLabel(folder: WorkspaceFileFolderRecord): string { } export const listFileFoldersServerTool: BaseServerTool = { - name: 'list_file_folders', + name: ListFileFolders.id, async execute( params: ListFileFoldersArgs, context?: ServerToolContext @@ -210,7 +215,7 @@ export const listFileFoldersServerTool: BaseServerTool = { - name: 'create_file_folder', + name: CreateFileFolder.id, async execute( params: CreateFileFolderArgs, context?: ServerToolContext @@ -231,23 +236,22 @@ export const createFileFolderServerTool: BaseServerTool 1) { - parentId = await ensureWorkspaceFileFolderPath({ + const resolvedParentId = await findWorkspaceFileFolderIdByPath( workspaceId, - userId: context.userId, - pathSegments: pathSegments.slice(0, -1), - }) + pathSegments.slice(0, -1) + ) + if (!resolvedParentId) { + return { + success: false, + message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, + } + } + parentId = resolvedParentId } assertServerToolNotAborted(context) @@ -281,7 +285,7 @@ export const createFileFolderServerTool: BaseServerTool = { - name: 'rename_file_folder', + name: RenameFileFolder.id, async execute( params: RenameFileFolderArgs, context?: ServerToolContext @@ -337,7 +341,7 @@ export const renameFileFolderServerTool: BaseServerTool = { - name: 'move_file_folder', + name: MoveFileFolder.id, async execute( params: MoveFileFolderArgs, context?: ServerToolContext @@ -437,7 +441,7 @@ export const deleteFileFolderServerTool: BaseServerTool 0 || result.deletedItems.files > 0, message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`, - data: { ...result.deletedItems, deletedFolderIds: folderIds }, + data: result.deletedItems, } } catch (error) { return { success: false, message: toError(error).message } @@ -446,7 +450,7 @@ export const deleteFileFolderServerTool: BaseServerTool = { - name: 'move_file', + name: MoveFile.id, async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { const workspaceId = await resolveWorkspaceId(params, context, 'write') diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index a6f9e9f63c8..10bac242eca 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { RenameFile } from '@/lib/copilot/generated/tool-catalog-v1' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, @@ -30,13 +31,8 @@ interface RenameFileResult { } } -/** - * Removed from the mothership catalog in favor of mv; the executor stays - * registered under its literal name so in-flight checkpoints paused on - * rename_file still resume. Delete after the mv release soaks. - */ export const renameFileServerTool: BaseServerTool = { - name: 'rename_file', + name: RenameFile.id, async execute(params: RenameFileArgs, context?: ServerToolContext): Promise { if (!context?.userId) { throw new Error('Authentication required') diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts deleted file mode 100644 index 8b22ee7d7a4..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' - -type SearchKnowledgeBaseArgs = { - operation: string - args?: Record -} - -type SearchKnowledgeBaseResult = { - success: boolean - message: string - data?: any -} - -const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) - -/** - * Read-only variant of knowledge_base for info-gathering agents. Copilot - * access control is a per-agent tool allowlist, so read-only access gets its - * own tool name with its own operation contract — enforced here (where - * execution happens) on top of the fail-fast guard in the Go executor. - */ -export const searchKnowledgeBaseServerTool: BaseServerTool< - SearchKnowledgeBaseArgs, - SearchKnowledgeBaseResult -> = { - name: SearchKnowledgeBase.id, - async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!READ_OPERATIONS.has(operation)) { - return { - success: false, - message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's knowledge_base tool`, - } - } - return knowledgeBaseServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 14afe0f80fe..9dd8c737dde 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { CreateFile, + CreateFileFolder, DeleteFile, DeleteFileFolder, DownloadToWorkspaceFile, @@ -15,6 +16,10 @@ import { ManageCustomTool, ManageMcpTool, ManageSkill, + MoveFile, + MoveFileFolder, + RenameFile, + RenameFileFolder, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -45,12 +50,10 @@ import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generat import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' 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 { 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' @@ -127,12 +130,12 @@ const WRITE_ACTIONS: Record = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], - rename_file: ['*'], + [RenameFile.id]: ['*'], [DeleteFile.id]: ['*'], - move_file: ['*'], - create_file_folder: ['*'], - rename_file_folder: ['*'], - move_file_folder: ['*'], + [MoveFile.id]: ['*'], + [CreateFileFolder.id]: ['*'], + [RenameFileFolder.id]: ['*'], + [MoveFileFolder.id]: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], [GenerateImage.id]: ['generate'], @@ -167,10 +170,8 @@ const baseServerToolRegistry: Record = { [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, - [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, [userTableServerTool.name]: userTableServerTool, - [queryUserTableServerTool.name]: queryUserTableServerTool, [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts deleted file mode 100644 index e2b07176da5..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { QueryUserTable } 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 QueryUserTableArgs = { - operation: string - args?: Record -} - -type QueryUserTableResult = { - success: boolean - message: string - data?: any -} - -const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) - -/** - * Read-only variant of user_table for info-gathering agents. Copilot access - * control is a per-agent tool allowlist, so read-only access gets its own tool - * name with its own operation contract — enforced here (where execution - * happens) on top of the fail-fast guard in the Go executor. outputPath is - * rejected because query_rows exports rows to a workspace file through it. - */ -export const queryUserTableServerTool: BaseServerTool = { - name: QueryUserTable.id, - async execute(params: QueryUserTableArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!READ_OPERATIONS.has(operation)) { - return { - success: false, - message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, - } - } - if (params?.args && 'outputPath' in params.args) { - return { - success: false, - message: - 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', - } - } - if (params && 'outputPath' in (params as Record)) { - return { - success: false, - message: - 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', - } - } - return userTableServerTool.execute(params, context) - }, -} 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 5f437d52ba0..540ea5f7d25 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -471,7 +471,6 @@ export const userTableServerTool: BaseServerTool return { success: deleted.length > 0, message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`, - data: { deleted, failed }, } } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts index 77fa6ae5323..346ec925d90 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts @@ -2,10 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { - createBlockFromParams, - normalizeSubblockValue, -} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders' +import { createBlockFromParams } from '@/lib/copilot/tools/server/workflow/edit-workflow/builders' const agentBlockConfig = { type: 'agent', @@ -23,25 +20,10 @@ const conditionBlockConfig = { subBlocks: [{ id: 'conditions', type: 'condition-input' }], } -const knowledgeBlockConfig = { - type: 'knowledge', - name: 'Knowledge', - outputs: {}, - subBlocks: [ - { id: 'tagFilters', type: 'knowledge-tag-filters' }, - { id: 'documentTags', type: 'document-tag-entry' }, - ], -} - -const blocksByType: Record = { - agent: agentBlockConfig, - condition: conditionBlockConfig, - knowledge: knowledgeBlockConfig, -} - vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [agentBlockConfig, conditionBlockConfig, knowledgeBlockConfig], - getBlock: (type: string) => blocksByType[type], + getAllBlocks: () => [agentBlockConfig, conditionBlockConfig], + getBlock: (type: string) => + type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined, })) describe('createBlockFromParams', () => { @@ -98,73 +80,4 @@ describe('createBlockFromParams', () => { const conditions = JSON.parse(block.subBlocks.conditions.value) expect(conditions.map(({ title }: { title: string }) => title)).toEqual(['if', 'else']) }) - - it('persists knowledge tag subblocks as JSON strings, not raw arrays', () => { - const block = createBlockFromParams('kb-1', { - type: 'knowledge', - name: 'Knowledge 1', - inputs: { - tagFilters: [{ tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }], - documentTags: [{ tagName: 'Team', tagSlot: 'tag2', value: 'infra' }], - }, - triggerMode: false, - }) - - expect(typeof block.subBlocks.tagFilters.value).toBe('string') - expect(typeof block.subBlocks.documentTags.value).toBe('string') - - const filters = JSON.parse(block.subBlocks.tagFilters.value) - expect(filters[0].tagName).toBe('Department') - expect(filters[0].id).toEqual(expect.any(String)) - }) -}) - -describe('normalizeSubblockValue', () => { - it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])( - 'serializes %s to a JSON string the subblock component can parse', - (key) => { - const result = normalizeSubblockValue(key, [{ id: 'not-a-uuid', title: 'a' }]) - - expect(typeof result).toBe('string') - expect(JSON.parse(result as string)[0].title).toBe('a') - } - ) - - it('accepts a JSON string as input and still returns a string', () => { - const result = normalizeSubblockValue('tagFilters', JSON.stringify([{ tagName: 'Department' }])) - - expect(typeof result).toBe('string') - expect(JSON.parse(result as string)[0].tagName).toBe('Department') - }) - - it('leaves array-with-id subblocks that are not string-serialized as raw arrays', () => { - const result = normalizeSubblockValue('inputFormat', [{ id: 'x', name: 'field' }]) - - expect(Array.isArray(result)).toBe(true) - }) - - it('passes through subblock keys that need no normalization', () => { - expect(normalizeSubblockValue('systemPrompt', 'hello')).toBe('hello') - }) - - // Validation treats null as an explicit clear. Coercing it to "[]" would persist a value - // where the caller asked for none, so the agent reads back an empty filter rather than an - // absent one -- the same absent-vs-empty ambiguity that caused the original data loss. - it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])( - 'passes a null %s through as a clear rather than serializing it to "[]"', - (key) => { - expect(normalizeSubblockValue(key, null)).toBeNull() - expect(normalizeSubblockValue(key, undefined)).toBeUndefined() - } - ) - - it('still serializes an explicitly empty array, which clears the field with a value', () => { - expect(normalizeSubblockValue('tagFilters', [])).toBe('[]') - }) - - it('replaces non-uuid ids so copilot-authored rows match UI-created ones', () => { - const result = normalizeSubblockValue('tagFilters', [{ id: 'filter-1', tagName: 'Department' }]) - - expect(JSON.parse(result as string)[0].id).not.toBe('filter-1') - }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts index 286dc576b1d..e3ca1ced888 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts @@ -93,7 +93,15 @@ export function createBlockFromParams( return } - let sanitizedValue = normalizeSubblockValue(key, value) + let sanitizedValue = value + + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { + sanitizedValue = JSON.stringify(sanitizedValue) + } + } sanitizedValue = normalizeConditionRouterIds(blockId, key, sanitizedValue) @@ -266,35 +274,29 @@ const ARRAY_WITH_ID_SUBBLOCK_TYPES = new Set([ * Subblock keys whose UI components expect a JSON string, not a raw array. * After normalizeArrayWithIds returns an array, these must be re-stringified. */ -const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes', 'tagFilters', 'documentTags']) - -/** - * Coerces a subblock value to an array, accepting either a raw array or the JSON string - * the string-serialized subblocks persist. - * - * @returns The array, or `null` when the value is not an array and does not parse to one. - * Callers supply their own fallback, which differs by site. - */ -function parseJsonArray(value: unknown): any[] | null { - if (Array.isArray(value)) return value - if (typeof value !== 'string') return null - - try { - const parsed = JSON.parse(value) - return Array.isArray(parsed) ? parsed : null - } catch { - return null - } -} +export const JSON_STRING_SUBBLOCK_KEYS = new Set(['conditions', 'routes']) /** * Normalizes array subblock values by ensuring each item has a valid UUID. * The LLM may generate arbitrary IDs like "input-desc-001" or "row-1" which need * to be converted to proper UUIDs for consistency with UI-created items. */ -function normalizeArrayWithIds(value: unknown): any[] { - const arr = parseJsonArray(value) - if (!arr) return [] +export function normalizeArrayWithIds(value: unknown): any[] { + let arr: any[] + + if (Array.isArray(value)) { + arr = value + } else if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return [] + arr = parsed + } catch { + return [] + } + } else { + return [] + } return arr.map((item: any) => { if (!item || typeof item !== 'object') { @@ -313,30 +315,10 @@ function normalizeArrayWithIds(value: unknown): any[] { /** * Checks if a subblock key should have its array items normalized with UUIDs. */ -function shouldNormalizeArrayIds(key: string): boolean { +export function shouldNormalizeArrayIds(key: string): boolean { return ARRAY_WITH_ID_SUBBLOCK_TYPES.has(key) } -/** - * Normalizes an array-with-id subblock value, re-serializing it to a JSON string for the - * subblock keys whose UI components read a string rather than a raw array. - * - * Every write path that persists LLM-supplied subblock values must route through this so the - * two concerns cannot drift apart; returns non-array-with-id values untouched. - * - * @remarks - * A nullish value passes through unchanged. `validateValueForSubBlockType` treats null as an - * explicit clear, and coercing it to `"[]"` here would persist a value where the caller asked - * for none -- leaving `sanitizeForCopilot` to show the agent an empty filter rather than an - * absent one, and callers that branch on the field's presence to see it as set. - */ -export function normalizeSubblockValue(key: string, value: unknown): unknown { - if (!shouldNormalizeArrayIds(key)) return value - if (value === null || value === undefined) return value - const normalized = normalizeArrayWithIds(value) - return JSON_STRING_SUBBLOCK_KEYS.has(key) ? JSON.stringify(normalized) : normalized -} - /** * Normalizes condition/router branch IDs to use canonical block-scoped format. * The LLM provides branch structure (if/else-if/else or routes) but should not @@ -345,8 +327,19 @@ export function normalizeSubblockValue(key: string, value: unknown): unknown { export function normalizeConditionRouterIds(blockId: string, key: string, value: unknown): unknown { if (key !== 'conditions' && key !== 'routes') return value - const parsed = parseJsonArray(value) - if (!parsed) return value + let parsed: any[] + if (typeof value === 'string') { + try { + parsed = JSON.parse(value) + if (!Array.isArray(parsed)) return value + } catch { + return value + } + } else if (Array.isArray(value)) { + parsed = value + } else { + return value + } let elseIfCounter = 0 const normalized = parsed.map((item, index) => { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index 7914f4eaf59..b2e27179d0b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -221,35 +221,6 @@ describe('handleEditOperation nestedNodes merge', () => { expect(state.blocks['new-agent']).toBeUndefined() }) - it('persists string-serialized subblocks as JSON strings on merged children', () => { - const workflow = makeLoopWorkflow() - - const { state } = applyOperationsToWorkflowState(workflow, [ - { - operation_type: 'edit', - block_id: 'loop-1', - params: { - nestedNodes: { - 'new-condition': { - type: 'condition', - name: 'Condition 1', - inputs: { - conditions: [ - { id: 'x', title: 'if', value: 'x > 1' }, - { id: 'y', title: 'else', value: '' }, - ], - }, - }, - }, - }, - }, - ]) - - const value = state.blocks['condition-1'].subBlocks.conditions.value - expect(typeof value).toBe('string') - expect(JSON.parse(value as string)[0].title).toBe('if') - }) - it('preserves edges for matched children when connections are not provided', () => { const workflow = makeLoopWorkflow() diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index 015791daad1..8e7e33df487 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -8,10 +8,12 @@ import { applyTriggerConfigToBlockSubblocks, createBlockFromParams, filterDisallowedTools, + JSON_STRING_SUBBLOCK_KEYS, + normalizeArrayWithIds, normalizeConditionRouterIds, normalizeResponseFormat, - normalizeSubblockValue, normalizeTools, + shouldNormalizeArrayIds, updateCanonicalModesForInputs, } from './builders' import type { EditWorkflowOperation, OperationContext } from './types' @@ -207,7 +209,10 @@ function mergeNestedNodesForParent( Object.entries(childValidation.validInputs).forEach(([key, value]) => { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) return - let sanitizedValue = normalizeSubblockValue(key, value) + let sanitizedValue = value + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + } sanitizedValue = normalizeConditionRouterIds(existingId, key, sanitizedValue) if (key === 'tools' && Array.isArray(value)) { sanitizedValue = filterDisallowedTools( @@ -439,7 +444,15 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } - let sanitizedValue = normalizeSubblockValue(key, value) + let sanitizedValue = value + + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { + sanitizedValue = JSON.stringify(sanitizedValue) + } + } sanitizedValue = normalizeConditionRouterIds(block_id, key, sanitizedValue) @@ -907,7 +920,15 @@ export function handleInsertIntoSubflowOperation( return } - let sanitizedValue = normalizeSubblockValue(key, value) + let sanitizedValue = value + + // Normalize array subblocks with id fields (inputFormat, table rows, etc.) + if (shouldNormalizeArrayIds(key)) { + sanitizedValue = normalizeArrayWithIds(value) + if (JSON_STRING_SUBBLOCK_KEYS.has(key)) { + sanitizedValue = JSON.stringify(sanitizedValue) + } + } sanitizedValue = normalizeConditionRouterIds(block_id, key, sanitizedValue) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index e9d8c30e5d4..1a83b20593a 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -68,11 +68,7 @@ const knowledgeBlockConfig = { type: 'knowledge', name: 'Knowledge', outputs: {}, - subBlocks: [ - { id: 'knowledgeBaseId', type: 'knowledge-base-selector' }, - { id: 'tagFilters', type: 'knowledge-tag-filters' }, - { id: 'documentTags', type: 'document-tag-entry' }, - ], + subBlocks: [{ id: 'knowledgeBaseId', type: 'knowledge-base-selector' }], } const canonicalCredBlockConfig = { @@ -127,17 +123,6 @@ const throwGateBlockConfig = { tools: { access: ['throw_gate_tool'], config: { tool: () => 'throw_gate_tool' } }, } -const genericWebhookBlockConfig = { - type: 'generic_webhook', - name: 'Webhook', - category: 'triggers', - outputs: {}, - subBlocks: [ - { id: 'webhookUrlDisplay', type: 'short-input', readOnly: true, useWebhookUrl: true }, - { id: 'requireAuth', type: 'switch' }, - ], -} - // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -203,9 +188,7 @@ vi.mock('@/blocks/registry', () => ({ ? throwGateBlockConfig : type === 'throw_selector_block' ? throwSelectorBlockConfig - : type === 'generic_webhook' - ? genericWebhookBlockConfig - : undefined, + : undefined, })) vi.mock('@/blocks/utils', () => ({ @@ -277,88 +260,6 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('expected a JSON array') }) - // Without this guard, normalizeArrayWithIds coerces any unparseable value to [], which the - // write path then persists as "[]" -- silently destroying a tag filter the user configured. - it.each([ - ['a double-encoded JSON string', JSON.stringify(JSON.stringify([{ tagName: 'Department' }]))], - ['an unparseable string', 'not-json'], - ['an object', { tagName: 'Department' }], - ['a number', 5], - ])('rejects knowledge-tag-filters values that are %s', (_label, value) => { - const result = validateInputsForBlock('knowledge', { tagFilters: value }, 'kb-1') - - expect(result.validInputs.tagFilters).toBeUndefined() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.error).toContain('expected a JSON array') - }) - - it('rejects non-array document-tag-entry values', () => { - const result = validateInputsForBlock('knowledge', { documentTags: 'not-json' }, 'kb-1') - - expect(result.validInputs.documentTags).toBeUndefined() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.error).toContain('expected a JSON array') - }) - - it.each([ - ['a JSON string array', JSON.stringify([{ tagName: 'Department', tagValue: 'IT' }])], - ['a raw array', [{ tagName: 'Department', tagValue: 'IT' }]], - ['an empty array, clearing the filter', []], - ])('accepts knowledge-tag-filters values that are %s', (_label, value) => { - const result = validateInputsForBlock('knowledge', { tagFilters: value }, 'kb-1') - - expect(result.errors).toHaveLength(0) - expect(result.validInputs.tagFilters).toBeDefined() - }) - - it('accepts a null knowledge-tag-filters value so the field can still be cleared', () => { - const result = validateInputsForBlock('knowledge', { tagFilters: null }, 'kb-1') - - expect(result.errors).toHaveLength(0) - expect(result.validInputs.tagFilters).toBeNull() - }) - - // The webhook URL is shown to the agent as a synthesized read-only field - // (sanitizeForCopilot); writes to it or to display-only subblocks must bounce - // with a clear error instead of persisting dead state. - it('rejects the synthesized triggerWebhookUrl field as read-only', () => { - const result = validateInputsForBlock( - 'generic_webhook', - { triggerWebhookUrl: 'https://evil.test/api/webhooks/trigger/x', requireAuth: true }, - 'hook-1' - ) - - expect(result.validInputs.triggerWebhookUrl).toBeUndefined() - expect(result.validInputs.requireAuth).toBe(true) - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.error).toContain('read-only') - }) - - it('rejects triggerWebhookUrl even for unknown block types that skip validation', () => { - const result = validateInputsForBlock( - 'not_a_real_block', - { triggerWebhookUrl: 'https://evil.test/hook', other: 'kept' }, - 'blk-1' - ) - - expect(result.validInputs.triggerWebhookUrl).toBeUndefined() - expect(result.validInputs.other).toBe('kept') - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.error).toContain('read-only') - }) - - it('rejects read-only display subblocks like webhookUrlDisplay', () => { - const result = validateInputsForBlock( - 'generic_webhook', - { webhookUrlDisplay: 'https://evil.test/hook' }, - 'hook-1' - ) - - expect(result.validInputs.webhookUrlDisplay).toBeUndefined() - expect(result.errors).toHaveLength(1) - expect(result.errors[0]?.error).toContain('read-only') - }) - it('accepts known agent model ids', () => { const result = validateInputsForBlock('agent', { model: 'claude-sonnet-4-6' }, 'agent-1') 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 9dd6e65a806..1bda4606fac 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 @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' @@ -18,7 +17,7 @@ import { getModelOptions } from '@/blocks/utils' import { EDGE, normalizeName } from '@/executor/constants' import { isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models' import { getTool } from '@/tools/utils' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' +import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' import type { EdgeHandleValidationResult, EditWorkflowOperation, @@ -56,27 +55,12 @@ export function validateInputsForBlock( blockId: string ): ValidationResult { const errors: ValidationError[] = [] - - // Synthesized by sanitizeForCopilot in the read view; derived, never stored. - // Rejected before the unknown-block-type early return below so it can never be - // written regardless of block type. - if (TRIGGER_WEBHOOK_URL_FIELD in inputs) { - errors.push({ - blockId, - blockType, - field: TRIGGER_WEBHOOK_URL_FIELD, - value: inputs[TRIGGER_WEBHOOK_URL_FIELD], - error: `"${TRIGGER_WEBHOOK_URL_FIELD}" is read-only. The webhook URL is auto-assigned by Sim and cannot be changed by the agent or the user.`, - }) - inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD]) - } - const blockConfig = getBlock(blockType) if (!blockConfig) { // Unknown block type - return inputs as-is (let it fail later if invalid) validationLogger.warn(`Unknown block type: ${blockType}, skipping validation`) - return { validInputs: inputs, errors } + return { validInputs: inputs, errors: [] } } const validatedInputs: Record = {} @@ -113,19 +97,6 @@ export function validateInputsForBlock( continue } - // Display-only fields (e.g. webhookUrlDisplay, samplePayload) are computed or - // static in the UI; a written value would be dead state the UI never shows. - if (subBlockConfig.readOnly === true) { - errors.push({ - blockId, - blockType, - field: key, - value, - error: `Field "${key}" on block type "${blockType}" is a read-only display field and cannot be set`, - }) - continue - } - // Note: We do NOT check subBlockConfig.condition here. // Conditions are for UI display logic (show/hide fields in the editor). // For API/Copilot, any valid field in the block schema should be accepted. @@ -396,9 +367,7 @@ export function validateValueForSubBlockType( } case 'condition-input': - case 'router-input': - case 'knowledge-tag-filters': - case 'document-tag-entry': { + case 'router-input': { const parsedValue = typeof value === 'string' ? (() => { diff --git a/apps/sim/lib/copilot/tools/streaming-args.ts b/apps/sim/lib/copilot/tools/streaming-args.ts deleted file mode 100644 index 80bfb112245..00000000000 --- a/apps/sim/lib/copilot/tools/streaming-args.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Extract a completed JSON string field from an argument buffer that may still be partial. */ -export function extractStreamingStringArgument( - input: string | undefined, - key: string -): string | undefined { - if (!input) return undefined - const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const match = new RegExp(`"${escapedKey}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(input) - if (!match?.[1]) return undefined - try { - return JSON.parse(`"${match[1]}"`) as string - } catch { - return undefined - } -} diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index da571a7cd01..353750417a2 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -2,66 +2,17 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { - FfmpegOperationValues, - KnowledgeBaseOperationValues, - MaterializeFileOperationValues, - QueryUserTableOperationValues, - SearchKnowledgeBaseOperationValues, - TOOL_CATALOG, - type ToolCatalogEntry, - UserTableOperationValues, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' import { getToolCompletedTitle, getToolDisplayTitle, - getToolStatusDisplayTitle, humanizeToolName, - mvDisplayVerb, } from '@/lib/copilot/tools/tool-display' -function representativeToolArgs(entry: ToolCatalogEntry): Record { - const args: Record = {} - if (!entry.parameters || typeof entry.parameters !== 'object') return args - const properties = (entry.parameters as { properties?: unknown }).properties - if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args - - for (const [key, rawSchema] of Object.entries(properties)) { - if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue - const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown } - if (schema.default !== undefined) { - args[key] = schema.default - } else if (Array.isArray(schema.enum) && schema.enum.length > 0) { - args[key] = schema.enum[0] - } else if (schema.type === 'boolean') { - args[key] = true - } else if (schema.type === 'object') { - args[key] = {} - } - } - return args -} - -function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[] { - if (!entry.parameters || typeof entry.parameters !== 'object') return [] - const properties = (entry.parameters as { properties?: unknown }).properties - if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return [] - const schema = (properties as Record)[property] - if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [] - const values = (schema as { enum?: unknown }).enum - return Array.isArray(values) ? values : [] -} - describe('humanizeToolName', () => { it('title-cases snake_case names', () => { expect(humanizeToolName('manage_folder')).toBe('Manage Folder') }) - it('title-cases kebab-case names', () => { - expect(humanizeToolName('read-oauth-integrations')).toBe('Read OAuth Integrations') - }) - it('keeps canonical acronym casing', () => { expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server') expect(humanizeToolName('deploy_api')).toBe('Deploy API') @@ -83,76 +34,6 @@ describe('getToolDisplayTitle natural-language coverage', () => { 'Crunching numbers' ) }) - - it('has an intentional display title for every visible catalog tool', () => { - const hiddenToolNames = getHiddenToolNames() - const fallbackToolNames = Object.keys(TOOL_CATALOG).filter( - (name) => !hiddenToolNames.has(name) && getToolDisplayTitle(name) === humanizeToolName(name) - ) - - expect(fallbackToolNames).toEqual([]) - }) - - it('has a completed-verb rewrite for every visible non-agent catalog tool', () => { - const hiddenToolNames = getHiddenToolNames() - const missingCompletedVerbs = Object.entries(TOOL_CATALOG).flatMap(([name, entry]) => { - if (entry.internal || hiddenToolNames.has(name)) return [] - const title = getToolDisplayTitle(name, representativeToolArgs(entry)) - return getToolCompletedTitle(title) ? [] : [`${name}: ${title}`] - }) - - expect(missingCompletedVerbs).toEqual([]) - }) - - it('resolves every catalog action and operation enum without a generic placeholder', () => { - const genericPlaceholders = new Set([ - 'Credential action', - 'Custom tool action', - 'Editing file', - 'Folder action', - 'MCP server action', - 'Managing knowledge base', - 'Managing table', - 'Preparing file', - 'Processing media', - 'Scheduled task action', - 'Skill action', - ]) - const unresolvedVariants: string[] = [] - - for (const [name, entry] of Object.entries(TOOL_CATALOG)) { - for (const property of ['action', 'operation']) { - for (const value of toolPropertyEnum(entry, property)) { - const title = getToolDisplayTitle(name, { - ...representativeToolArgs(entry), - [property]: value, - title: 'resource', - }) - if (genericPlaceholders.has(title) || !getToolCompletedTitle(title)) { - unresolvedVariants.push(`${name}.${property}=${String(value)}: ${title}`) - } - } - } - } - - expect(unresolvedVariants).toEqual([]) - }) -}) - -describe('getToolDisplayTitle for deployments', () => { - it.each([ - ['deploy_api', undefined, 'Deploying API'], - ['deploy_api', { action: 'deploy' }, 'Deploying API'], - ['deploy_api', { action: 'undeploy' }, 'Undeploying API'], - ['deploy_chat', { action: 'deploy' }, 'Deploying chat'], - ['deploy_chat', { action: 'undeploy' }, 'Undeploying chat'], - ['deploy_custom_block', { action: 'deploy' }, 'Deploying custom block'], - ['deploy_custom_block', { action: 'undeploy' }, 'Undeploying custom block'], - ['deploy_mcp', undefined, 'Deploying MCP tool'], - ['redeploy', undefined, 'Redeploying API'], - ])('uses the action and deployment type for %s', (toolName, args, expected) => { - expect(getToolDisplayTitle(toolName, args)).toBe(expected) - }) }) describe('getToolCompletedTitle', () => { @@ -167,10 +48,6 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow') expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow') expect(getToolCompletedTitle('Reading file')).toBe('Read file') - expect(getToolCompletedTitle('Undeploying API')).toBe('Undeployed API') - expect(getToolCompletedTitle('Duplicating workflow')).toBe('Duplicated workflow') - expect(getToolCompletedTitle('Viewing custom tools')).toBe('Viewed custom tools') - expect(getToolCompletedTitle('Saving report.pdf')).toBe('Saved report.pdf') }) it('returns undefined for non-gerund titles', () => { @@ -178,297 +55,4 @@ describe('getToolCompletedTitle', () => { expect(getToolCompletedTitle('Folder action')).toBeUndefined() expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined() }) - - it('projects completed titles only for successful rows', () => { - expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows') - expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe( - 'Comparing workflows' - ) - expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows') - }) -}) - -describe('mvDisplayVerb', () => { - it('reads a leaf-only change in the same folder as a rename', () => { - expect(mvDisplayVerb('workflows/falling-vacuum', 'workflows/failing-vacuum')).toBe('Renaming') - expect(mvDisplayVerb('files/Reports/a.md', 'files/Reports/b.md')).toBe('Renaming') - expect(mvDisplayVerb('tables/Leads', 'tables/Customers')).toBe('Renaming') - }) - - it('decodes segments so encoded sources compare against plain destinations', () => { - expect(mvDisplayVerb('workflows/My%20Flow', 'workflows/New Flow')).toBe('Renaming') - expect(mvDisplayVerb('files/My%20Docs/a.md', 'files/My Docs/b.md')).toBe('Renaming') - }) - - it('reads parent changes and folder destinations as moves', () => { - expect(mvDisplayVerb('files/a.png', 'files/Images/')).toBe('Moving') - expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/a.md')).toBe('Moving') - expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/b.md')).toBe('Moving') - expect(mvDisplayVerb('workflows/My Flow', 'workflows/Archive/')).toBe('Moving') - }) - - it('falls back to Moving when arguments are incomplete', () => { - expect(mvDisplayVerb(undefined, 'files/x.md')).toBe('Moving') - expect(mvDisplayVerb('files/x.md', undefined)).toBe('Moving') - }) -}) - -describe('getToolDisplayTitle for the vfs verbs', () => { - it('shows the created file name', () => { - expect( - getToolDisplayTitle('create_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_file', { - outputs: { files: [{ path: 'files/notes.md', mode: 'overwrite' }] }, - }) - ).toBe('Overwriting notes.md') - expect(getToolDisplayTitle('create_file')).toBe('Creating file') - }) - - it('shows deleted file and folder names', () => { - expect( - getToolDisplayTitle('delete_file', { - paths: ['files/Reports/Old%20Report.pdf'], - }) - ).toBe('Deleting Old Report.pdf') - expect( - getToolDisplayTitle('delete_file_folder', { - paths: ['files/Old%20Reports', 'files/Drafts'], - }) - ).toBe('Deleting Old Reports and Drafts') - }) - - it('uses the derived verb for mv titles', () => { - expect( - getToolDisplayTitle('mv', { - sources: ['workflows/falling-vacuum'], - destination: 'workflows/failing-vacuum', - toolTitle: 'falling-vacuum to failing-vacuum', - }) - ).toBe('Renaming falling-vacuum to failing-vacuum') - expect( - getToolDisplayTitle('mv', { - sources: ['files/a.png', 'files/b.png'], - destination: 'files/Images/', - toolTitle: '2 files to Images', - }) - ).toBe('Moving 2 files to Images') - }) - - it('titles cp and mkdir by intent', () => { - expect(getToolDisplayTitle('cp', { toolTitle: 'My Workflow' })).toBe('Duplicating My Workflow') - expect(getToolDisplayTitle('mkdir', { toolTitle: 'Reports/2026' })).toBe( - 'Creating Reports/2026' - ) - expect(getToolDisplayTitle('cp', {})).toBe('Duplicating workflow') - expect(getToolDisplayTitle('mkdir', {})).toBe('Creating folder') - }) -}) - -describe('getToolDisplayTitle for workflow resources', () => { - it('shows workflow names for lifecycle actions', () => { - expect(getToolDisplayTitle('create_workflow', { name: 'Lead Router' })).toBe( - 'Creating Lead Router' - ) - expect(getToolDisplayTitle('edit_workflow', { workflowName: 'Lead Router' })).toBe( - 'Editing Lead Router' - ) - expect( - getToolDisplayTitle('delete_workflow', { - workflowNames: ['Lead Router', 'Lead Enricher'], - }) - ).toBe('Deleting Lead Router and Lead Enricher') - }) -}) - -describe('getToolDisplayTitle for managed resources', () => { - it.each([ - [ - 'manage_custom_tool', - { - operation: 'add', - schema: { function: { name: 'lookupWeather' } }, - }, - 'Creating lookupWeather', - ], - ['manage_mcp_tool', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'], - ['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'], - [ - 'manage_scheduled_task', - { operation: 'create', args: { title: 'Morning Digest' } }, - 'Creating Morning Digest', - ], - [ - 'manage_credential', - { - operation: 'rename', - previousDisplayName: 'Stripe', - displayName: 'Production Stripe', - }, - 'Renaming Stripe to Production Stripe', - ], - [ - 'manage_folder', - { operation: 'rename', path: 'workflows/Old%20Name', name: 'New Name' }, - 'Renaming Old Name to New Name', - ], - [ - 'manage_folder', - { operation: 'delete', path: 'workflows/Marketing/Q3%20Campaigns' }, - 'Deleting Q3 Campaigns', - ], - ['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'], - ['manage_mcp_tool', { operation: 'list' }, 'Viewing MCP servers'], - ['manage_skill', { operation: 'list' }, 'Viewing skills'], - ['manage_scheduled_task', { operation: 'get' }, 'Reading scheduled task'], - ['manage_scheduled_task', { operation: 'list' }, 'Viewing scheduled tasks'], - ])('uses verb + resource name for %s', (toolName, args, expected) => { - expect(getToolDisplayTitle(toolName, args)).toBe(expected) - }) -}) - -describe('getToolDisplayTitle for operation-driven tools', () => { - it('covers every FFmpeg operation with a specific activity', () => { - for (const operation of FfmpegOperationValues) { - expect(getToolDisplayTitle('ffmpeg', { operation })).not.toBe('Processing media') - } - expect(getToolDisplayTitle('ffmpeg', { operation: 'probe' })).toBe('Inspecting media') - expect(getToolDisplayTitle('ffmpeg', { operation: 'extract_audio' })).toBe('Extracting audio') - }) - - it('covers every knowledge-base operation with its actual verb and resource', () => { - for (const operation of KnowledgeBaseOperationValues) { - expect(getToolDisplayTitle('knowledge_base', { operation })).not.toBe( - 'Managing knowledge base' - ) - } - expect(getToolDisplayTitle('knowledge_base', { operation: 'query' })).toBe( - 'Searching knowledge base' - ) - expect(getToolDisplayTitle('knowledge_base', { operation: 'sync_connector' })).toBe( - 'Syncing knowledge base connector' - ) - }) - - it('covers every read-only table and knowledge-base operation', () => { - for (const operation of QueryUserTableOperationValues) { - expect(getToolDisplayTitle('query_user_table', { operation })).not.toBe('Query User Table') - } - for (const operation of SearchKnowledgeBaseOperationValues) { - expect(getToolDisplayTitle('search_knowledge_base', { operation })).not.toBe( - 'Search Knowledge Base' - ) - } - expect(getToolDisplayTitle('query_user_table', { operation: 'get_schema' })).toBe( - 'Reading table schema' - ) - expect(getToolDisplayTitle('search_knowledge_base', { operation: 'list_tags' })).toBe( - 'Listing knowledge base tags' - ) - }) - - it('covers every table operation with a specific activity', () => { - for (const operation of UserTableOperationValues) { - expect(getToolDisplayTitle('user_table', { operation })).not.toBe('Managing table') - } - expect( - getToolDisplayTitle('user_table', { - operation: 'rename_column', - args: { columnName: 'status', newName: 'stage' }, - }) - ).toBe('Renaming column status to stage') - expect(getToolDisplayTitle('user_table', { operation: 'cancel_table_runs' })).toBe( - 'Cancelling table runs' - ) - }) - - it('distinguishes saving uploads from importing workflows', () => { - for (const operation of MaterializeFileOperationValues) { - expect( - getToolDisplayTitle('materialize_file', { operation, fileNames: ['Lead Router.json'] }) - ).not.toBe('Preparing file') - } - expect( - getToolDisplayTitle('materialize_file', { - operation: 'save', - fileNames: ['Quarterly Report.pdf'], - }) - ).toBe('Saving Quarterly Report.pdf') - expect( - getToolDisplayTitle('materialize_file', { - operation: 'import', - fileNames: ['Lead Router.json'], - }) - ).toBe('Importing Lead Router.json') - }) - - it('uses boolean and resource-type arguments where they change the action', () => { - expect(getToolDisplayTitle('set_block_enabled', { enabled: true })).toBe('Enabling block') - expect(getToolDisplayTitle('set_block_enabled', { enabled: false })).toBe('Disabling block') - expect(getToolDisplayTitle('restore_resource', { type: 'knowledgebase' })).toBe( - 'Restoring knowledge base' - ) - expect(getToolDisplayTitle('open_resource', { resources: [{ type: 'scheduledtask' }] })).toBe( - 'Opening scheduled task' - ) - }) - - it('includes deployment versions when available', () => { - expect(getToolDisplayTitle('load_deployment', { version: 'live' })).toBe( - 'Loading live deployment' - ) - expect(getToolDisplayTitle('load_deployment', { version: '5' })).toBe( - 'Loading deployment version 5' - ) - expect(getToolDisplayTitle('promote_to_live', { version: 5 })).toBe( - 'Promoting version 5 to live' - ) - expect(getToolDisplayTitle('update_deployment_version', { version: 5 })).toBe( - 'Updating deployment version 5' - ) - }) - - it('uses the integration, variable scope, and nested variable operations', () => { - expect(getToolDisplayTitle('list_integration_tools', { integration: 'google_sheets' })).toBe( - 'Listing Google Sheets tools' - ) - expect(getToolDisplayTitle('set_environment_variables', { scope: 'personal' })).toBe( - 'Setting personal environment variables' - ) - expect( - getToolDisplayTitle('set_global_workflow_variables', { - operations: [{ operation: 'delete', name: 'OLD_URL' }], - }) - ).toBe('Deleting workflow variable OLD_URL') - expect( - getToolDisplayTitle('set_global_workflow_variables', { - operations: [ - { operation: 'add', name: 'API_URL' }, - { operation: 'edit', name: 'TIMEOUT' }, - ], - }) - ).toBe('Updating 2 workflow variables') - }) -}) - -describe('getToolDisplayTitle for request-scoped MCP tools', () => { - it('hides the internal server id and humanizes the tool name', () => { - expect(getToolDisplayTitle('mcp-363de040-web_search_exa')).toBe('Web Search Exa') - expect(getToolDisplayTitle('mcp-363de040-read-oauth-integrations')).toBe( - 'Read OAuth Integrations' - ) - }) -}) - -describe('getToolDisplayTitle for context management', () => { - it('describes compaction in user-facing language', () => { - expect(getToolDisplayTitle('context_compaction')).toBe('Summarizing context') - expect(getToolStatusDisplayTitle('Summarizing context', 'success')).toBe('Summarized context') - }) }) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 8cc89868cc6..099503901d1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -16,8 +16,6 @@ import { stripVersionSuffix } from '@sim/utils/string' type ToolArgs = Record | undefined -export const CONTEXT_COMPACTION_DISPLAY_TITLE = 'Summarizing context' - function stringArg(args: ToolArgs, key: string): string { const value = args?.[key] return typeof value === 'string' ? value.trim() : '' @@ -43,46 +41,13 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): return firstStringArg(parent as Record, ...keys) } -function recordArg(args: ToolArgs, key: string): Record | undefined { - const value = args?.[key] - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined -} - -function stringOrNumberArg(args: ToolArgs, key: string): string { - const value = args?.[key] - return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '' -} - -function deploymentTitle(args: ToolArgs, deploymentType: string): string { - return `${stringArg(args, 'action') === 'undeploy' ? 'Undeploying' : 'Deploying'} ${deploymentType}` -} - -function resourceTypeLabel(type: string): string { - const labels: Record = { - knowledgebase: 'knowledge base', - scheduledtask: 'scheduled task', - file_folder: 'file folder', - log: 'logs', - } - return labels[type] ?? type -} - -interface OperationDisplay { - verb: string - resource: string -} - -function namedOperationTitle( +function operationTitle( args: ToolArgs, - target: string, placeholder: string, - labels: Record + labels: Record ): string { const operation = stringArg(args, 'operation') - const display = labels[operation] - return display ? `${display.verb} ${target || display.resource}` : placeholder + return labels[operation] ?? placeholder } function isWorkflowArtifactPath(path: string, filename: string): boolean { @@ -90,300 +55,6 @@ function isWorkflowArtifactPath(path: string, filename: string): boolean { return trimmed.startsWith('workflows/') && trimmed.endsWith(`/${filename}`) } -function decodePathSegment(segment: string): string { - try { - return decodeURIComponent(segment) - } catch { - return segment - } -} - -function pathLeaf(path: string): string { - const normalized = path.replace(/\/+$/, '') - const leaf = normalized.split('/').filter(Boolean).at(-1) || normalized - return decodePathSegment(leaf) -} - -function summarizeTargets(targets: string[], fallback: string): string { - const normalized = targets.map((target) => target.trim()).filter(Boolean) - if (normalized.length === 0) return fallback - if (normalized.length === 1) return normalized[0] - if (normalized.length === 2) return `${normalized[0]} and ${normalized[1]}` - return `${normalized[0]}, ${normalized[1]}, and ${normalized.length - 2} more` -} - -function countedResourceTarget( - args: ToolArgs, - key: string, - singular: string, - plural: string -): string { - const values = args?.[key] - return Array.isArray(values) && values.length > 1 ? `${values.length} ${plural}` : singular -} - -function firstOutputFilePath(args: ToolArgs): string { - const outputs = args?.outputs - if (!outputs || typeof outputs !== 'object') return '' - const files = (outputs as Record).files - if (!Array.isArray(files)) return '' - - for (const file of files) { - if (!file || typeof file !== 'object') continue - const path = stringArg(file as Record, 'path') - if (path) return path - } - return '' -} - -function firstOutputFileMode(args: ToolArgs): string { - const outputs = args?.outputs - if (!outputs || typeof outputs !== 'object') return '' - const files = (outputs as Record).files - if (!Array.isArray(files)) return '' - - for (const file of files) { - if (!file || typeof file !== 'object') continue - const mode = stringArg(file as Record, 'mode') - if (mode) return mode - } - return '' -} - -function createFileTitle(args: ToolArgs): string { - const nestedArgs = - args?.args && typeof args.args === 'object' ? (args.args as Record) : undefined - const target = - firstOutputFilePath(args) || - firstStringArg(args, 'fileName') || - firstOutputFilePath(nestedArgs) || - firstStringArg(nestedArgs, 'fileName') - const mode = firstOutputFileMode(args) || firstOutputFileMode(nestedArgs) - const verb = mode === 'overwrite' ? 'Overwriting' : 'Creating' - if (!target) return `${verb} file` - return `${verb} ${pathLeaf(target)}` -} - -function ffmpegTitle(args: ToolArgs): string { - const titles: Record = { - overlay_audio: 'Adding audio to media', - mix_audio: 'Mixing audio', - concat: 'Combining media', - trim: 'Trimming media', - scale_pad: 'Resizing media', - overlay_image: 'Adding image to media', - add_text: 'Adding text to media', - fade: 'Adding fade to media', - extract_audio: 'Extracting audio', - convert: 'Converting media', - thumbnail: 'Creating thumbnail', - probe: 'Inspecting media', - } - return titles[stringArg(args, 'operation')] ?? 'Processing media' -} - -function knowledgeBaseTitle(args: ToolArgs): string { - const operation = stringArg(args, 'operation') - const operationArgs = recordArg(args, 'args') - const name = stringArg(operationArgs, 'name') - const tagName = stringArg(operationArgs, 'tagDisplayName') - const fileTarget = summarizeTargets( - stringArrayArg(operationArgs, 'filePaths').map(pathLeaf), - 'file' - ) - - const titles: Record = { - create: `Creating ${name || 'knowledge base'}`, - get: 'Reading knowledge base', - query: 'Searching knowledge base', - add_file: `Adding ${fileTarget} to knowledge base`, - update: 'Updating knowledge base', - delete: `Deleting ${countedResourceTarget(operationArgs, 'knowledgeBaseIds', 'knowledge base', 'knowledge bases')}`, - delete_document: `Deleting ${countedResourceTarget(operationArgs, 'documentIds', 'document', 'documents')}`, - update_document: 'Updating document', - list_tags: 'Listing knowledge base tags', - create_tag: `Creating ${tagName || 'knowledge base tag'}`, - update_tag: `Updating ${tagName || 'knowledge base tag'}`, - delete_tag: 'Deleting knowledge base tag', - get_tag_usage: 'Checking tag usage', - add_connector: 'Adding knowledge base connector', - update_connector: 'Updating knowledge base connector', - delete_connector: 'Deleting knowledge base connector', - sync_connector: 'Syncing knowledge base connector', - } - return titles[operation] ?? 'Managing knowledge base' -} - -function queryUserTableTitle(args: ToolArgs): string { - const titles: Record = { - get: 'Reading table', - get_schema: 'Reading table schema', - get_row: 'Reading table row', - query_rows: 'Querying table', - } - return titles[stringArg(args, 'operation')] ?? 'Querying table' -} - -function searchKnowledgeBaseTitle(args: ToolArgs): string { - const titles: Record = { - get: 'Reading knowledge base', - query: 'Searching knowledge base', - list_tags: 'Listing knowledge base tags', - } - return titles[stringArg(args, 'operation')] ?? 'Searching knowledge base' -} - -function userTableTitle(args: ToolArgs): string { - const operation = stringArg(args, 'operation') - const operationArgs = recordArg(args, 'args') - const name = stringArg(operationArgs, 'name') - const newName = stringArg(operationArgs, 'newName') - const columnName = stringArg(operationArgs, 'columnName') - const columnDefinitionName = nestedStringArg(operationArgs, 'column', 'name') - const columnTargets = [ - ...stringArrayArg(operationArgs, 'columnNames'), - ...(columnName ? [columnName] : []), - ] - - switch (operation) { - case 'create': - return `Creating ${name || 'table'}` - case 'create_from_file': - return 'Creating table from file' - case 'import_file': - return 'Importing file into table' - case 'get': - return 'Reading table' - case 'get_schema': - return 'Reading table schema' - case 'delete': - return `Deleting ${countedResourceTarget(operationArgs, 'tableIds', 'table', 'tables')}` - case 'rename': - return newName ? `Renaming table to ${newName}` : 'Renaming table' - case 'insert_row': - return 'Adding table row' - case 'batch_insert_rows': - return `Adding ${countedResourceTarget(operationArgs, 'rows', 'table row', 'table rows')}` - case 'get_row': - return 'Reading table row' - case 'query_rows': - return 'Querying table' - case 'update_row': - return 'Updating table row' - case 'delete_row': - return 'Deleting table row' - case 'update_rows_by_filter': - case 'batch_update_rows': - return 'Updating table rows' - case 'delete_rows_by_filter': - case 'batch_delete_rows': - return 'Deleting table rows' - case 'add_column': - return columnDefinitionName ? `Adding column ${columnDefinitionName}` : 'Adding table column' - case 'rename_column': - if (columnName && newName) return `Renaming column ${columnName} to ${newName}` - return newName ? `Renaming table column to ${newName}` : 'Renaming table column' - case 'delete_column': - return `Deleting ${summarizeTargets(columnTargets, 'table column')}` - case 'update_column': - return columnName ? `Updating column ${columnName}` : 'Updating table column' - case 'add_workflow_group': - return 'Adding table workflow' - case 'update_workflow_group': - return 'Updating table workflow' - case 'delete_workflow_group': - return 'Deleting table workflow' - case 'add_workflow_group_output': - return 'Adding workflow output column' - case 'delete_workflow_group_output': - return 'Deleting workflow output column' - case 'run_column': - return 'Running table workflow' - case 'cancel_table_runs': - return 'Cancelling table runs' - case 'list_workflow_outputs': - return 'Listing workflow outputs' - case 'list_enrichments': - return 'Listing enrichments' - case 'add_enrichment': - return `Adding ${name || 'enrichment'}` - default: - return 'Managing table' - } -} - -function materializeFileTitle(args: ToolArgs): string { - const operation = stringArg(args, 'operation') || 'save' - const targets = stringArrayArg(args, 'fileNames').map(pathLeaf) - if (operation === 'import') { - return `Importing ${summarizeTargets(targets, 'workflow')}` - } - return `Saving ${summarizeTargets(targets, 'file')}` -} - -function openResourceTitle(args: ToolArgs): string { - const resources = args?.resources - if (!Array.isArray(resources) || resources.length === 0) return 'Opening resource' - if (resources.length > 1) return `Opening ${resources.length} resources` - const resource = resources[0] - if (!resource || typeof resource !== 'object') return 'Opening resource' - const type = stringArg(resource as Record, 'type') - return `Opening ${type ? resourceTypeLabel(type) : 'resource'}` -} - -function setGlobalWorkflowVariablesTitle(args: ToolArgs): string { - const operations = args?.operations - if (!Array.isArray(operations) || operations.length === 0) return 'Setting workflow variables' - - const parsed = operations.filter( - (operation): operation is Record => - Boolean(operation) && typeof operation === 'object' && !Array.isArray(operation) - ) - const operationNames = parsed.map((operation) => stringArg(operation, 'operation')) - const firstOperation = operationNames[0] - const allSameOperation = - firstOperation && operationNames.every((operation) => operation === firstOperation) - const verbByOperation: Record = { - add: 'Adding', - edit: 'Updating', - delete: 'Deleting', - } - const verb = allSameOperation ? (verbByOperation[firstOperation] ?? 'Updating') : 'Updating' - - if (parsed.length === 1) { - const variableName = stringArg(parsed[0], 'name') - return `${verb} workflow variable${variableName ? ` ${variableName}` : ''}` - } - return `${verb} ${parsed.length} workflow variables` -} - -/** - * Verb for an mv call, derived from its arguments so the row reads as what - * the call actually does: a single source whose parent path matches the - * destination's (only the leaf changes) is a rename; multiple sources, a - * trailing-slash folder destination, or a parent change is a move. Segments - * are decoded so an encoded source compares correctly against a plain-text - * destination leaf. - */ -export function mvDisplayVerb( - source: string | undefined, - destination: string | undefined -): 'Renaming' | 'Moving' { - if (!source || !destination || /\/\s*$/.test(destination)) return 'Moving' - const segments = (path: string) => - path - .trim() - .replace(/^\/+|\/+$/g, '') - .split('/') - .map(decodePathSegment) - const src = segments(source) - const dst = segments(destination) - if (src.length < 2 || dst.length < 2) return 'Moving' - const sameParent = src.slice(0, -1).join('/') === dst.slice(0, -1).join('/') - const leafChanged = src.at(-1) !== dst.at(-1) - return sameParent && leafChanged ? 'Renaming' : 'Moving' -} - function workspaceFileTitle(args: ToolArgs): string { const title = stringArg(args, 'title') if (!title) return '' @@ -401,21 +72,15 @@ function workspaceFileTitle(args: ToolArgs): string { /** Static fallback titles for tools without an argument-aware title. */ const TOOL_TITLES: Record = { - // Gateway rows brand from the streamed toolId as soon as it resolves; this - // covers only the instant before the integration is known. The raw - // humanized name ("Call Integration Tool") must never render. - call_integration_tool: 'Calling integration', read: 'Reading file', search_library_docs: 'Searching library docs', + user_memory: 'Accessing memory', user_table: 'Managing table', - run_code: 'Running code', - query_user_table: 'Querying table', workspace_file: 'Editing file', edit_content: 'Applying file content', create_workflow: 'Creating workflow', edit_workflow: 'Editing workflow', knowledge_base: 'Managing knowledge base', - search_knowledge_base: 'Searching knowledge base', open_resource: 'Opening resource', generate_image: 'Generating image', generate_video: 'Generating video', @@ -434,7 +99,7 @@ const TOOL_TITLES: Record = { deploy_api: 'Deploying API', deploy_chat: 'Deploying chat', deploy_custom_block: 'Deploying custom block', - deploy_mcp: 'Deploying MCP tool', + deploy_mcp: 'Deploying MCP server', diff_workflows: 'Comparing workflows', download_to_workspace_file: 'Downloading file', function_execute: 'Running code', @@ -448,7 +113,7 @@ const TOOL_TITLES: Record = { get_workflow_data: 'Getting workflow data', get_workflow_run_options: 'Getting run options', list_file_folders: 'Listing folders', - list_integration_tools: 'Listing integration tools', + list_integration_tools: 'Listing integrations', list_user_workspaces: 'Listing workspaces', list_workspace_mcp_servers: 'Listing MCP servers', load_deployment: 'Loading deployment', @@ -459,7 +124,7 @@ const TOOL_TITLES: Record = { oauth_get_auth_link: 'Getting authorization link', oauth_request_access: 'Requesting access', promote_to_live: 'Promoting to live', - redeploy: 'Redeploying API', + redeploy: 'Redeploying', rename_file: 'Renaming file', rename_file_folder: 'Renaming folder', rename_workflow: 'Renaming workflow', @@ -483,13 +148,8 @@ const TOOL_TITLES: Record = { scheduled_task: 'Scheduled Task Agent', agent: 'Tools Agent', research: 'Research Agent', - scout: 'Scout Agent', - search: 'Search Agent', - file: 'File Agent', media: 'Media Agent', superagent: 'Executing action', - respond: 'Gathering thoughts', - context_compaction: CONTEXT_COMPACTION_DISPLAY_TITLE, } /** Acronyms that must keep their canonical casing when humanized. */ @@ -502,34 +162,16 @@ const ACRONYM_CASING: Record = { ai: 'AI', } -/** - * Humanize an internal identifier without leaking snake_case or kebab-case into - * the UI. Sentence case is useful for resource names appended to a verb, while - * title case is used for standalone tool-name fallbacks. - */ -export function humanizeDisplayIdentifier( - name: string, - casing: 'sentence' | 'title' = 'title' -): string { - const words = stripVersionSuffix(name).split(/[-_]+/).filter(Boolean) - if (words.length === 0) return name - return words - .map((word, index) => { - const normalized = word.toLowerCase() - const acronym = ACRONYM_CASING[normalized] - if (acronym) return acronym - if (casing === 'sentence' && index > 0) return normalized - return normalized.charAt(0).toUpperCase() + normalized.slice(1) - }) - .join(' ') -} - /** * Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage * Folder"), matching the legacy client humanizer so labels never render blank. */ export function humanizeToolName(name: string): string { - return humanizeDisplayIdentifier(name) + const words = stripVersionSuffix(name).split('_').filter(Boolean) + if (words.length === 0) return name + return words + .map((word) => ACRONYM_CASING[word] ?? word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') } /** @@ -538,110 +180,7 @@ export function humanizeToolName(name: string): string { * returns an empty string. */ export function getToolDisplayTitle(name: string, args?: Record): string { - const mcpToolMatch = name.match(/^mcp-[^-]+-(.+)$/) - if (mcpToolMatch?.[1]) { - return humanizeToolName(mcpToolMatch[1]) - } - switch (name) { - case 'deploy_api': - return deploymentTitle(args, 'API') - case 'deploy_chat': - return deploymentTitle(args, 'chat') - case 'deploy_custom_block': - return deploymentTitle(args, 'custom block') - case 'ffmpeg': - return ffmpegTitle(args) - case 'knowledge_base': - return knowledgeBaseTitle(args) - case 'query_user_table': - return queryUserTableTitle(args) - case 'search_knowledge_base': - return searchKnowledgeBaseTitle(args) - case 'user_table': - return userTableTitle(args) - case 'materialize_file': - return materializeFileTitle(args) - case 'open_resource': - return openResourceTitle(args) - case 'restore_resource': { - const type = stringArg(args, 'type') - return `Restoring ${type ? resourceTypeLabel(type) : 'resource'}` - } - case 'set_block_enabled': { - const enabled = args?.enabled - return typeof enabled === 'boolean' - ? `${enabled ? 'Enabling' : 'Disabling'} block` - : 'Toggling block' - } - case 'load_deployment': { - const version = stringOrNumberArg(args, 'version') - if (!version) return 'Loading deployment' - return version === 'live' - ? 'Loading live deployment' - : `Loading deployment version ${version}` - } - case 'promote_to_live': { - const version = stringOrNumberArg(args, 'version') - return version ? `Promoting version ${version} to live` : 'Promoting to live' - } - case 'update_deployment_version': { - const version = stringOrNumberArg(args, 'version') - return version ? `Updating deployment version ${version}` : 'Updating deployment' - } - case 'generate_api_key': { - const keyName = stringArg(args, 'name') - return keyName ? `Generating API key ${keyName}` : 'Generating API key' - } - case 'list_integration_tools': { - const integration = stringArg(args, 'integration') - return integration - ? `Listing ${humanizeToolName(integration)} tools` - : 'Listing integration tools' - } - case 'set_environment_variables': { - const scope = stringArg(args, 'scope') || 'workspace' - return `Setting ${scope} environment variables` - } - case 'set_global_workflow_variables': - return setGlobalWorkflowVariablesTitle(args) - case 'create_file': - return createFileTitle(args) - case 'delete_file': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'file')}` - } - case 'delete_file_folder': { - const targets = stringArrayArg(args, 'paths').map(pathLeaf) - return `Deleting ${summarizeTargets(targets, 'folder')}` - } - case 'create_workflow': { - const target = firstStringArg(args, 'name', 'workflowName', 'title') - return `Creating ${target || 'workflow'}` - } - case 'edit_workflow': { - const target = firstStringArg(args, 'workflowName', 'name', 'title') - return `Editing ${target || 'workflow'}` - } - case 'delete_workflow': { - const target = summarizeTargets( - stringArrayArg(args, 'workflowNames'), - countedResourceTarget(args, 'workflowIds', 'workflow', 'workflows') - ) - return `Deleting ${target}` - } - case 'create_workspace_mcp_server': { - const target = firstStringArg(args, 'name', 'serverName', 'title') - return `Creating ${target || 'MCP server'}` - } - case 'update_workspace_mcp_server': { - const target = firstStringArg(args, 'name', 'serverName', 'title') - return `Updating ${target || 'MCP server'}` - } - case 'delete_workspace_mcp_server': { - const target = firstStringArg(args, 'serverName', 'name', 'title') - return `Deleting ${target || 'MCP server'}` - } case 'search_online': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' @@ -654,25 +193,6 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Finding ${target}` : 'Finding files' } - case 'mv': { - const sources = stringArrayArg(args, 'sources') - const verb = - sources.length === 1 ? mvDisplayVerb(sources[0], stringArg(args, 'destination')) : 'Moving' - if (verb === 'Renaming' && sources[0]) { - const destination = stringArg(args, 'destination') - if (destination) return `Renaming ${pathLeaf(sources[0])} to ${pathLeaf(destination)}` - } - const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `${verb} ${target}` : verb - } - case 'cp': { - const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `Duplicating ${target}` : 'Duplicating workflow' - } - case 'mkdir': { - const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `Creating ${target}` : 'Creating folder' - } case 'enrichment_run': { const subject = nestedStringArg( args, @@ -699,90 +219,47 @@ export function getToolDisplayTitle(name: string, args?: Record if (urls.length > 1) return `Getting ${urls.length} pages` return 'Getting page contents' } - case 'manage_custom_tool': { - const schema = args?.schema - const target = - firstStringArg(args, 'toolTitle', 'title', 'name') || - (schema && typeof schema === 'object' - ? nestedStringArg(schema as Record, 'function', 'name') - : '') - return namedOperationTitle(args, target, 'Custom tool action', { - add: { verb: 'Creating', resource: 'custom tool' }, - edit: { verb: 'Updating', resource: 'custom tool' }, - delete: { verb: 'Deleting', resource: 'custom tool' }, - list: { verb: 'Viewing', resource: 'custom tools' }, + case 'manage_custom_tool': + return operationTitle(args, 'Custom tool action', { + add: 'Creating custom tool', + edit: 'Updating custom tool', + delete: 'Deleting custom tool', + list: 'Listing custom tools', }) - } - case 'manage_mcp_tool': { - const target = - firstStringArg(args, 'serverName', 'name', 'title') || - nestedStringArg(args, 'config', 'name') - return namedOperationTitle(args, target, 'MCP server action', { - add: { verb: 'Creating', resource: 'MCP server' }, - edit: { verb: 'Updating', resource: 'MCP server' }, - delete: { verb: 'Deleting', resource: 'MCP server' }, - list: { verb: 'Viewing', resource: 'MCP servers' }, + case 'manage_mcp_tool': + return operationTitle(args, 'MCP server action', { + add: 'Creating MCP server', + edit: 'Updating MCP server', + delete: 'Deleting MCP server', + list: 'Listing MCP servers', }) - } - case 'manage_skill': { - const target = firstStringArg(args, 'name', 'skillName', 'title') - return namedOperationTitle(args, target, 'Skill action', { - add: { verb: 'Creating', resource: 'skill' }, - edit: { verb: 'Updating', resource: 'skill' }, - delete: { verb: 'Deleting', resource: 'skill' }, - list: { verb: 'Viewing', resource: 'skills' }, + case 'manage_skill': + return operationTitle(args, 'Skill action', { + add: 'Creating skill', + edit: 'Updating skill', + delete: 'Deleting skill', + list: 'Listing skills', }) - } - case 'manage_scheduled_task': { - const target = - firstStringArg(args, 'title', 'taskName', 'name') || nestedStringArg(args, 'args', 'title') - return namedOperationTitle(args, target, 'Scheduled task action', { - create: { verb: 'Creating', resource: 'scheduled task' }, - get: { verb: 'Reading', resource: 'scheduled task' }, - update: { verb: 'Updating', resource: 'scheduled task' }, - delete: { verb: 'Deleting', resource: 'scheduled task' }, - list: { verb: 'Viewing', resource: 'scheduled tasks' }, + case 'manage_scheduled_task': + return operationTitle(args, 'Scheduled task action', { + create: 'Creating scheduled task', + get: 'Getting scheduled task', + update: 'Updating scheduled task', + delete: 'Deleting scheduled task', + list: 'Listing scheduled tasks', }) - } - case 'manage_credential': { - const operation = stringArg(args, 'operation') - if (operation === 'rename') { - const from = firstStringArg(args, 'previousDisplayName', 'oldName', 'credentialName') - const to = firstStringArg(args, 'displayName', 'newName', 'name', 'title') - if (from && to) return `Renaming ${from} to ${to}` - return to ? `Renaming credential to ${to}` : 'Renaming credential' - } - const target = firstStringArg(args, 'credentialName', 'displayName', 'name', 'title') - return namedOperationTitle(args, target, 'Credential action', { - delete: { verb: 'Deleting', resource: 'credential' }, + case 'manage_credential': + return operationTitle(args, 'Credential action', { + rename: 'Renaming credential', + delete: 'Deleting credential', }) - } - case 'manage_folder': { - const operation = stringArg(args, 'operation') - if (operation === 'rename') { - const rawFrom = firstStringArg(args, 'oldPath', 'source', 'path', 'folderName') - const rawTo = firstStringArg(args, 'newPath', 'destination', 'newName', 'name', 'title') - const from = rawFrom ? pathLeaf(rawFrom) : '' - const to = rawTo ? pathLeaf(rawTo) : '' - if (from && to) return `Renaming ${from} to ${to}` - return to ? `Renaming folder to ${to}` : 'Renaming folder' - } - const rawTarget = firstStringArg( - args, - 'newPath', - 'destination', - 'path', - 'folderName', - 'name', - 'title' - ) - const target = rawTarget ? pathLeaf(rawTarget) : '' - return namedOperationTitle(args, target, 'Folder action', { - create: { verb: 'Creating', resource: 'folder' }, - move: { verb: 'Moving', resource: 'folder' }, - delete: { verb: 'Deleting', resource: 'folder' }, + case 'manage_folder': + return operationTitle(args, 'Folder action', { + create: 'Creating folder', + rename: 'Renaming folder', + move: 'Moving folder', + delete: 'Deleting folder', }) - } case 'run_workflow': case 'run_from_block': case 'run_workflow_until_block': @@ -817,38 +294,25 @@ const COMPLETED_VERB_REWRITES: Record = { Accessing: 'Accessed', Adding: 'Added', Applying: 'Applied', - Cancelling: 'Cancelled', - Calling: 'Called', Checking: 'Checked', - Combining: 'Combined', Comparing: 'Compared', Completing: 'Completed', - Converting: 'Converted', Crawling: 'Crawled', Creating: 'Created', Deleting: 'Deleted', Deploying: 'Deployed', - Disabling: 'Disabled', Downloading: 'Downloaded', - Duplicating: 'Duplicated', Editing: 'Edited', - Enabling: 'Enabled', Executing: 'Executed', - Extracting: 'Extracted', - Fading: 'Faded', Finding: 'Found', Gathering: 'Gathered', Generating: 'Generated', Getting: 'Got', - Importing: 'Imported', - Inspecting: 'Inspected', Listing: 'Listed', Loading: 'Loaded', Managing: 'Managed', - Mixing: 'Mixed', Moving: 'Moved', Opening: 'Opened', - Overwriting: 'Overwrote', Preparing: 'Prepared', Processing: 'Processed', Promoting: 'Promoted', @@ -857,21 +321,14 @@ const COMPLETED_VERB_REWRITES: Record = { Redeploying: 'Redeployed', Renaming: 'Renamed', Requesting: 'Requested', - Resizing: 'Resized', Restoring: 'Restored', Running: 'Ran', - Saving: 'Saved', Scraping: 'Scraped', Searching: 'Searched', Setting: 'Set', - Summarizing: 'Summarized', - Syncing: 'Synced', Toggling: 'Toggled', - Trimming: 'Trimmed', - Undeploying: 'Undeployed', Updating: 'Updated', Validating: 'Validated', - Viewing: 'Viewed', Writing: 'Wrote', } @@ -880,9 +337,7 @@ const COMPLETED_VERB_REWRITES: Record = { * completed tool call (e.g. "Querying logs for X" -> "Queried logs for X"). * Operates on the already-resolved title so enriched and persisted titles both * work. Returns undefined when the title has no leading gerund rewrite — the - * caller keeps the original. Integration gateway descriptions are base-form - * verb phrases ("Read recent emails") whose first word never matches a gerund - * key, so they intentionally pass through unchanged. + * caller keeps the original. */ export function getToolCompletedTitle(title: string): string | undefined { const spaceIndex = title.indexOf(' ') @@ -891,13 +346,3 @@ export function getToolCompletedTitle(title: string): string | undefined { if (!past) return undefined return past + title.slice(firstWord.length) } - -/** - * Resolve the final title for a tool status at a rendering boundary. Persisted - * and live snapshots intentionally keep the present-tense activity title so a - * running/error row remains truthful; every successful renderer calls this to - * project the corresponding completed title from the canonical verb map. - */ -export function getToolStatusDisplayTitle(title: string, status: string): string { - return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title -} diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts index 0acdbbda432..f0181d0a396 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -281,96 +281,6 @@ describe('resource writer workflow aliases', () => { ) }) - it('auto-creates missing parent folders for plain workspace file creates', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ - id: 'file-report', - name: 'summary.csv', - size: 7, - type: 'text/csv', - url: '/download', - }) - - const result = await writeWorkspaceFileByPath({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/Reports/2026/summary.csv', - mode: 'create', - }, - buffer: Buffer.from('content'), - inferredMimeType: 'text/csv', - }) - - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'summary.csv', - 'text/csv', - { folderId: 'folder-nested' } - ) - expect(result).toMatchObject({ - id: 'file-report', - vfsPath: 'files/Reports/2026/summary.csv', - mode: 'create', - }) - }) - - it('validates create targets read-only, resolving existing parent folders without creating', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') - mocks.getWorkspaceFileByName.mockResolvedValue(null) - - const validation = await validateWorkspaceFileWriteTarget({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/Reports/2026/summary.csv', - mode: 'create', - }, - }) - - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - expect(validation).toMatchObject({ - mode: 'create', - vfsPath: 'files/Reports/2026/summary.csv', - fileName: 'summary.csv', - folderId: 'folder-nested', - }) - }) - - it('accepts create targets with missing parent folders during validation without creating them', async () => { - mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) - - const validation = await validateWorkspaceFileWriteTarget({ - workspaceId: 'workspace-1', - userId: 'user-1', - target: { - path: 'files/Reports/2026/summary.csv', - mode: 'create', - }, - }) - - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() - expect(mocks.getWorkspaceFileByName).not.toHaveBeenCalled() - expect(validation).toMatchObject({ - mode: 'create', - vfsPath: 'files/Reports/2026/summary.csv', - fileName: 'summary.csv', - folderId: null, - }) - }) - it('reports alias path when exact-name alias backing creation conflicts', async () => { mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ kind: 'plan_file', diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 148c0ee0333..460f6ba36dd 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -57,7 +57,6 @@ export type WorkspaceFileWriteValidation = vfsPath: string backingVfsPath?: string fileName: string - /** Null for root targets AND for parent chains that don't exist yet — validation is read-only; missing folders are created at write time. */ folderId: string | null } | { @@ -98,41 +97,22 @@ export function parseWorkspaceFileCreatePath(path: string): { } } -/** - * Resolve a create-mode write target. Pass `createFolders` (the write path) to - * create missing parent folders; without it (the validation path) resolution - * is read-only — a missing parent chain yields `folderId: null`, since the - * folders are created at write time and nothing can conflict there yet. - */ async function resolveCreateTarget( workspaceId: string, - path: string, - createFolders?: { userId: string } + path: string ): Promise { const parsed = parseWorkspaceFileCreatePath(path) - let folderId: string | null = null - if (parsed.folderSegments.length > 0) { - if (createFolders) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: createFolders.userId, - pathSegments: parsed.folderSegments, - }) - if (!folderId) { - throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) - } - } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { - includeReservedSystemFolders: true, - }) - if (!folderId) { - return { - fileName: parsed.fileName, - folderId: null, - vfsPath: parsed.vfsPath, - } - } - } + const folderId = + parsed.folderSegments.length > 0 + ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) + : null + + if (parsed.folderSegments.length > 0 && !folderId) { + throw new Error( + `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` + ) } const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) @@ -408,9 +388,7 @@ export async function writeWorkspaceFileByPath(args: { } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { - userId: args.userId, - }) + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) const uploaded = await uploadWorkspaceFile( args.workspaceId, args.userId, diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts deleted file mode 100644 index 728db16cd6d..00000000000 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import type { BlockConfig } from '@/blocks/types' -import { hostedKeyEnabledWhen } from '@/tools/hosting' -import type { ToolConfig } from '@/tools/types' -import { - serializeApiKeyIntegrations, - serializeBlockSchema, - serializeFileMeta, - serializeIntegrationSchema, - serializeKBMeta, - serializeTableMeta, - serializeWorkflowMeta, -} from './serializers' - -function hostedTool(id: string, conditional = false): ToolConfig { - return { - id, - name: id, - description: `Run ${id}`, - version: '1.0.0', - params: { - provider: { type: 'string', required: conditional }, - apiKey: { type: 'string', required: true, visibility: 'user-only' }, - }, - request: { - url: 'https://example.com', - method: 'POST', - headers: () => ({}), - }, - hosting: { - enabled: conditional - ? hostedKeyEnabledWhen({ field: 'provider', operator: 'equals', value: 'hosted' }) - : undefined, - envKeyPrefix: 'EXAMPLE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'exa', - pricing: { type: 'per_request', cost: 0.01 }, - rateLimit: { mode: 'per_request', requestsPerMinute: 10 }, - }, - } -} - -describe('VFS metadata serializers', () => { - it('includes the authoritative file update timestamp', () => { - const metadata = JSON.parse( - serializeFileMeta({ - id: 'file-1', - name: 'notes.md', - contentType: 'text/markdown', - size: 42, - uploadedAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T12:34:56.000Z'), - }) - ) - - expect(metadata.updatedAt).toBe('2026-07-09T12:34:56.000Z') - }) - - it('preserves live table and knowledge-base counts', () => { - const table = JSON.parse( - serializeTableMeta({ - id: 'table-1', - name: 'Customers', - schema: { columns: [] }, - rowCount: 137, - maxRows: 10_000, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T00:00:00.000Z'), - }) - ) - const knowledgeBase = JSON.parse( - serializeKBMeta({ - id: 'kb-1', - name: 'Handbook', - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - tokenCount: 12_345, - documentCount: 19, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T00:00:00.000Z'), - }) - ) - - expect(table.rowCount).toBe(137) - expect(knowledgeBase.documentCount).toBe(19) - }) - - it('never includes a workflow description in workflow metadata', () => { - const workflowWithPrivateDescription = { - id: 'workflow-1', - name: 'Private Flow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - folderId: null, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-02T00:00:00.000Z'), - } - - const metadata = JSON.parse(serializeWorkflowMeta(workflowWithPrivateDescription)) - - expect(metadata).not.toHaveProperty('description') - expect(JSON.stringify(metadata)).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - }) -}) - -describe('hosted-key VFS metadata', () => { - it('indexes hosted and conditional-hosted operations for every configured service', () => { - const metadata = JSON.parse( - serializeApiKeyIntegrations( - [ - { config: hostedTool('search'), service: 'generic_search', operation: 'search' }, - { - config: hostedTool('generate', true), - service: 'generic_search', - operation: 'generate', - }, - ], - true - ) - ) - - expect(metadata.generic_search).toEqual({ - params: ['apiKey'], - operations: ['search', 'generate'], - hostedOperations: ['search'], - conditionalHostedOperations: ['generate'], - }) - }) - - it('marks an operation as hosted and omits only its managed API-key param', () => { - const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: true })) - - expect(schema.auth).toEqual({ - type: 'api_key', - param: 'apiKey', - mode: 'hosted_or_byok', - provider: 'exa', - }) - expect(schema.params).not.toHaveProperty('apiKey') - }) - - it('keeps the API-key param and publishes the exact condition for conditional hosting', () => { - const schema = JSON.parse( - serializeIntegrationSchema(hostedTool('generate', true), { hosted: true }) - ) - - expect(schema.auth).toEqual({ - type: 'api_key', - param: 'apiKey', - mode: 'conditional_hosted_or_byok', - provider: 'exa', - condition: { field: 'provider', operator: 'equals', value: 'hosted' }, - }) - expect(schema.params.apiKey).toBeDefined() - }) - - it('marks the same operation as BYOK-required outside hosted Sim', () => { - const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: false })) - - expect(schema.auth.mode).toBe('byok_required') - expect(schema.params.apiKey).toBeDefined() - }) - - it('preserves a visible duplicate API-key field for mixed-operation blocks', () => { - const block = { - type: 'mixed_search', - name: 'Mixed Search', - description: 'Search or research', - category: 'tools', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Hosted search', id: 'search' }, - { label: 'Research with BYOK', id: 'research' }, - ], - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - hideWhenHosted: true, - condition: { field: 'operation', value: 'search' }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - condition: { field: 'operation', value: 'research' }, - }, - ], - tools: { access: ['search'] }, - inputs: { operation: { type: 'string' }, apiKey: { type: 'string' } }, - outputs: {}, - } as unknown as BlockConfig - const schema = JSON.parse( - serializeBlockSchema(block, { - hosted: true, - toolConfigs: new Map([['search', hostedTool('search')]]), - }) - ) - - expect(schema.subBlocks.filter((subBlock: { id: string }) => subBlock.id === 'apiKey')).toEqual( - [expect.objectContaining({ condition: { field: 'operation', value: 'research' } })] - ) - expect(schema.inputs.apiKey).toBeDefined() - expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') - }) -}) - -describe('serializeKBMeta', () => { - const baseKb = { - id: 'kb-1', - name: 'Support Docs', - description: null, - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - tokenCount: 42, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - documentCount: 3, - } - - it('includes tag definitions when present', () => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [ - { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text' }, - { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text' }, - ], - }) - ) - - const textOperators = ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'ends_with'] - expect(json.tagDefinitions).toEqual([ - { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text', operators: textOperators }, - { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text', operators: textOperators }, - ]) - }) - - // `between` is legal for number/date but not text/boolean -- the agent cannot infer this. - it.each([ - ['number', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], - ['date', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], - ['boolean', ['eq', 'neq']], - ])('exposes the operators legal for a %s tag', (fieldType, expected) => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType }], - }) - ) - - expect(json.tagDefinitions[0].operators).toEqual(expected) - }) - - it('emits an empty operator list for an unrecognized field type rather than throwing', () => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType: 'mystery' }], - }) - ) - - expect(json.tagDefinitions[0].operators).toEqual([]) - }) - - it('omits tag definitions when empty or undefined', () => { - const empty = JSON.parse(serializeKBMeta({ ...baseKb, tagDefinitions: [] })) - const missing = JSON.parse(serializeKBMeta(baseKb)) - - expect(empty).not.toHaveProperty('tagDefinitions') - expect(missing).not.toHaveProperty('tagDefinitions') - }) -}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index c5cd191208f..571768bad6a 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,58 +1,11 @@ +import { truncate } from '@sim/utils/string' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' -import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import { isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' -import type { ToolConfig, ToolHostingCondition } from '@/tools/types' - -export type VfsToolAuth = - | { - type: 'oauth' - required: boolean - provider: string - } - | { - type: 'api_key' - param: string - mode: 'hosted_or_byok' | 'conditional_hosted_or_byok' | 'byok_required' - provider?: string - condition?: ToolHostingCondition - } - -export interface ComponentSerializationOptions { - hosted?: boolean - toolConfigs?: ReadonlyMap -} - -/** - * Project runtime tool authentication into a stable, machine-readable VFS contract. - * ToolConfig.hosting remains the source of truth for every hosted-key integration. - */ -export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined { - if (tool.oauth) { - return { - type: 'oauth', - required: tool.oauth.required, - provider: tool.oauth.provider, - } - } - - if (!tool.hosting) return undefined - - return { - type: 'api_key', - param: tool.hosting.apiKeyParam, - mode: hosted - ? tool.hosting.enabled - ? 'conditional_hosted_or_byok' - : 'hosted_or_byok' - : 'byok_required', - provider: tool.hosting.byokProviderId, - condition: hosted ? tool.hosting.enabled?.condition : undefined, - } -} +import type { ToolConfig } from '@/tools/types' /** * Serialize workflow metadata for VFS meta.json. @@ -68,6 +21,7 @@ export function serializeWorkflowMeta( wf: { id: string name: string + description?: string | null folderId?: string | null isDeployed: boolean deployedAt?: Date | null @@ -85,6 +39,7 @@ export function serializeWorkflowMeta( { id: wf.id, name: wf.name, + description: wf.description || undefined, folderId: wf.folderId || undefined, locked, lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined, @@ -130,26 +85,7 @@ export function serializeRecentExecutions( } /** - * A knowledge base tag definition, reduced to the fields the agent needs to bind a tag filter. - * - * @remarks - * `tagName` is the DB's `displayName`. It is renamed at this boundary because that is the key - * a `tagFilters` entry must carry -- an entry written with `displayName` validates and persists - * but never filters anything. - */ -export interface KbTagDefinitionSummary { - tagName: string - tagSlot: string - fieldType: string -} - -/** - * Serialize knowledge base metadata for VFS meta.json. - * - * `tagDefinitions` exposes the KB's defined tags (`tagName` → `tagSlot`) plus the operators - * legal for each tag's `fieldType`, so the agent can bind a knowledge-tag filter without - * guessing a tag name it cannot otherwise see or an operator the field does not accept - * (`between` is valid for number/date but not text/boolean). + * Serialize knowledge base metadata for VFS meta.json */ export function serializeKBMeta(kb: { id: string @@ -162,7 +98,6 @@ export function serializeKBMeta(kb: { updatedAt: Date documentCount: number connectorTypes?: string[] - tagDefinitions?: KbTagDefinitionSummary[] }): string { return JSON.stringify( { @@ -175,15 +110,6 @@ export function serializeKBMeta(kb: { documentCount: kb.documentCount, connectorTypes: kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined, - tagDefinitions: - kb.tagDefinitions && kb.tagDefinitions.length > 0 - ? kb.tagDefinitions.map((tag) => ({ - ...tag, - operators: getOperatorsForFieldType(tag.fieldType as FilterFieldType).map( - (op) => op.value - ), - })) - : undefined, createdAt: kb.createdAt.toISOString(), updatedAt: kb.updatedAt.toISOString(), }, @@ -365,7 +291,6 @@ export function serializeFileMeta(file: { contentType: string size: number uploadedAt: Date - updatedAt: Date }): string { return JSON.stringify( { @@ -377,7 +302,6 @@ export function serializeFileMeta(file: { contentType: file.contentType, size: file.size, uploadedAt: file.uploadedAt.toISOString(), - updatedAt: file.updatedAt.toISOString(), readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, @@ -472,8 +396,6 @@ function serializeSubBlock(sb: SubBlockConfig): Record { if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue if (sb.mode) result.mode = sb.mode if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId - if (sb.condition && typeof sb.condition !== 'function') result.condition = sb.condition - if (sb.dependsOn) result.dependsOn = sb.dependsOn // Include static options arrays for dropdowns if (Array.isArray(sb.options)) { @@ -486,43 +408,28 @@ function serializeSubBlock(sb: SubBlockConfig): Record { /** * Serialize a block schema for VFS components/blocks/{type}.json */ -export function serializeBlockSchema( - block: BlockConfig, - options?: ComponentSerializationOptions -): string { +export function serializeBlockSchema(block: BlockConfig): string { // Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks; // treat `hidden` as hidden for them so those never reach the agent's schema. const customBlock = isCustomBlockType(block.type) - const hosted = options?.hosted ?? isHosted - const visibleSubBlocks = block.subBlocks.filter( - (sb) => !isSubBlockHidden(sb, { hosted }) && !(customBlock && sb.hidden) - ) - const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) const hiddenIds = new Set( block.subBlocks - .filter((sb) => isSubBlockHidden(sb, { hosted }) || (customBlock && sb.hidden)) + .filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden)) .map((sb) => sb.id) - .filter((id) => !visibleIds.has(id)) ) - const subBlocks = visibleSubBlocks.map((sb) => { - const serialized = serializeSubBlock(sb) - - if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { - serialized.options = getStaticModelOptionsForVFS() - serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE - } + const subBlocks = block.subBlocks + .filter((sb) => !hiddenIds.has(sb.id)) + .map((sb) => { + const serialized = serializeSubBlock(sb) - return serialized - }) + if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { + serialized.options = getStaticModelOptionsForVFS() + serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE + } - const toolAuth: Record = {} - for (const toolId of block.tools.access) { - const tool = options?.toolConfigs?.get(toolId) - if (!tool) continue - const auth = serializeToolAuth(tool, hosted) - if (auth) toolAuth[toolId] = auth - } + return serialized + }) const inputs = block.inputs && hiddenIds.size > 0 @@ -539,14 +446,12 @@ export function serializeBlockSchema( bestPractices: block.bestPractices || undefined, triggerAllowed: block.triggerAllowed || undefined, singleInstance: block.singleInstance || undefined, - authMode: block.authMode || undefined, // Custom (deploy-as-block) blocks execute via a baked `workflow_executor` // internally; that's implementation plumbing, not something the agent // configures. Hiding it keeps the block self-contained (fields in, outputs // out) so the agent doesn't treat it like the generic workflow block and // ask for a workflowId/inputMapping. tools: isCustomBlockType(block.type) ? [] : block.tools.access, - toolAuth: Object.keys(toolAuth).length > 0 ? toolAuth : undefined, subBlocks, inputs, outputs: Object.fromEntries( @@ -621,55 +526,6 @@ export function serializeApiKeys( ) } -interface ApiKeyIntegrationTool { - config: ToolConfig - service: string - operation: string - preview?: boolean -} - -/** - * Serialize API-key integration discovery with operation-level hosted status. - * ToolConfig.hosting is the only provider registry used to build this index. - */ -export function serializeApiKeyIntegrations( - tools: ApiKeyIntegrationTool[], - hosted = isHosted -): string { - const services = new Map< - string, - { - params: string[] - operations: string[] - hostedOperations: string[] - conditionalHostedOperations: string[] - } - >() - - for (const { config: tool, service, operation, preview } of tools) { - if (preview || !tool.hosting?.apiKeyParam) continue - - const metadata = services.get(service) ?? { - params: [], - operations: [], - hostedOperations: [], - conditionalHostedOperations: [], - } - if (!metadata.params.includes(tool.hosting.apiKeyParam)) { - metadata.params.push(tool.hosting.apiKeyParam) - } - metadata.operations.push(operation) - if (hosted && tool.hosting.enabled) { - metadata.conditionalHostedOperations.push(operation) - } else if (hosted) { - metadata.hostedOperations.push(operation) - } - services.set(service, metadata) - } - - return JSON.stringify(Object.fromEntries(services), null, 2) -} - /** * Serialize environment variables for VFS environment/variables.json. * Shows variable NAMES only — NOT values. @@ -813,7 +669,7 @@ export function serializeCustomTool(tool: { id: tool.id, title: tool.title, schema: tool.schema, - code: tool.code, + codePreview: truncate(tool.code, 500), }, null, 2 @@ -860,7 +716,7 @@ export function serializeSkill(s: { id: s.id, name: s.name, description: s.description, - content: s.content, + contentPreview: truncate(s.content, 500), createdAt: s.createdAt.toISOString(), }, null, @@ -871,14 +727,8 @@ export function serializeSkill(s: { /** * Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json */ -export function serializeIntegrationSchema( - tool: ToolConfig, - options?: Pick -): string { - const hosted = options?.hosted ?? isHosted - const auth = serializeToolAuth(tool, hosted) - const hostedApiKeyParam = - auth?.type === 'api_key' && auth.mode === 'hosted_or_byok' ? auth.param : null +export function serializeIntegrationSchema(tool: ToolConfig): string { + const hostedApiKeyParam = isHosted && tool.hosting ? tool.hosting.apiKeyParam : null return JSON.stringify( { @@ -887,9 +737,8 @@ export function serializeIntegrationSchema( // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, - description: getCopilotToolDescription(tool, { isHosted: hosted }), + description: getCopilotToolDescription(tool, { isHosted }), version: tool.version, - auth, oauth: tool.oauth ? { required: tool.oauth.required, provider: tool.oauth.provider } : undefined, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index d2cdf671eb1..8305ccaf121 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -3,13 +3,10 @@ import { db } from '@sim/db' import { chat as chatTable, copilotChats, - customTools as customToolsTable, document, jobExecutionLogs, - knowledgeBaseTagDefinitions, knowledgeConnector, mcpServers as mcpServersTable, - skill as skillTable, workflowDeploymentVersion, workflowExecutionLogs, workflowFolder, @@ -19,7 +16,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' +import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' import { buildWorkspaceContextMd, @@ -51,9 +48,8 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' -import type { DeploymentData, KbTagDefinitionSummary } from '@/lib/copilot/vfs/serializers' +import type { DeploymentData } from '@/lib/copilot/vfs/serializers' import { - serializeApiKeyIntegrations, serializeApiKeys, serializeBlockSchema, serializeBuiltinTriggerSchema, @@ -93,7 +89,7 @@ import { workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' -import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, @@ -113,13 +109,13 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowDeploymentSnapshot, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { getSkillById } from '@/lib/workflows/skills/operations' +import { listSkills } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess, @@ -134,7 +130,6 @@ import type { BlockConfig, BlockIcon } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import type { ToolConfig } from '@/tools/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') @@ -229,28 +224,23 @@ function getStaticComponentFiles(): Map { // files (overviews, oauth/api-key summaries) that all viewers receive. const allBlocks = Object.values(BLOCK_REGISTRY) const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar) - const exposedTools = getExposedIntegrationTools() - const toolConfigs = new Map() - for (const { toolId, config } of exposedTools) { - toolConfigs.set(toolId, config) - toolConfigs.set(config.id, config) - } let blocksFiltered = 0 for (const block of visibleBlocks) { const path = `components/blocks/${block.type}.json` - files.set(path, serializeBlockSchema(block, { toolConfigs })) + files.set(path, serializeBlockSchema(block)) } blocksFiltered = allBlocks.length - visibleBlocks.length let integrationCount = 0 const oauthServices = new Map() + const apiKeyServices = new Map() // Integration tools come from the shared exposed-tool set (latest version of // each operation owned by a visible block), the same set used to build the // deferred callable tools — so discovery and execution can never drift. - for (const exposedTool of exposedTools) { + for (const exposedTool of getExposedIntegrationTools()) { const { config: tool, service, operation, blockType, preview } = exposedTool const path = `components/integrations/${service}/${operation}.json` files.set(path, serializeIntegrationSchema(tool)) @@ -268,6 +258,19 @@ function getStaticComponentFiles(): Map { } else { oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] }) } + } else if (tool.hosting?.apiKeyParam) { + const existing = apiKeyServices.get(service) + if (existing) { + if (!existing.params.includes(tool.hosting.apiKeyParam)) { + existing.params.push(tool.hosting.apiKeyParam) + } + existing.operations.push(operation) + } else { + apiKeyServices.set(service, { + params: [tool.hosting.apiKeyParam], + operations: [operation], + }) + } } } @@ -277,7 +280,7 @@ function getStaticComponentFiles(): Map { ) files.set( 'environment/api-key-integrations.json', - serializeApiKeyIntegrations(exposedTools, isHosted) + JSON.stringify(Object.fromEntries(apiKeyServices), null, 2) ) files.set( @@ -1520,6 +1523,7 @@ export class WorkspaceVFS { return workflowRows.map((wf) => ({ id: wf.id, name: wf.name, + description: wf.description, isDeployed: wf.isDeployed, lastRunAt: wf.lastRunAt, folderPath: wf.folderId ? (folderPaths.get(wf.folderId) ?? null) : null, @@ -1536,8 +1540,6 @@ export class WorkspaceVFS { ): Promise { const kbs = await getKnowledgeBases(userId, workspaceId) - const tagDefinitionsByKb = await this.loadKbTagDefinitions(kbs.map((kb) => kb.id)) - await Promise.all( kbs.map(async (kb) => { const safeName = sanitizeName(kb.name) @@ -1556,7 +1558,6 @@ export class WorkspaceVFS { updatedAt: kb.updatedAt, documentCount: kb.docCount, connectorTypes: kb.connectorTypes, - tagDefinitions: tagDefinitionsByKb.get(kb.id), }) ) @@ -1628,61 +1629,6 @@ export class WorkspaceVFS { })) } - /** - * Load tag definitions for the given knowledge bases in a single query, grouped by - * KB id and ordered by tag slot. Surfaced inline in each KB's meta.json so the agent - * knows which tags exist (and their slot binding) when editing a knowledge-tag filter. - * - * @remarks - * Tag definitions are an optional enrichment, so a query failure degrades to a meta.json - * without them rather than rejecting. This materializer runs inside the top-level - * `Promise.all`, whose rejection would fail the entire workspace VFS build and leave the - * agent unable to read any file. - */ - private async loadKbTagDefinitions( - kbIds: string[] - ): Promise> { - const byKb = new Map() - if (kbIds.length === 0) return byKb - - let rows: Array<{ - knowledgeBaseId: string - tagSlot: string - displayName: string - fieldType: string - }> - try { - rows = await db - .select({ - knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId, - tagSlot: knowledgeBaseTagDefinitions.tagSlot, - displayName: knowledgeBaseTagDefinitions.displayName, - fieldType: knowledgeBaseTagDefinitions.fieldType, - }) - .from(knowledgeBaseTagDefinitions) - .where(inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, kbIds)) - .orderBy(knowledgeBaseTagDefinitions.tagSlot) - } catch (err) { - logger.warn('Failed to load knowledge base tag definitions', { - error: toError(err).message, - }) - return byKb - } - - for (const row of rows) { - const entry = { - tagName: row.displayName, - tagSlot: row.tagSlot, - fieldType: row.fieldType, - } - const existing = byKb.get(row.knowledgeBaseId) - if (existing) existing.push(entry) - else byKb.set(row.knowledgeBaseId, [entry]) - } - - return byKb - } - /** * Materialize tables using the shared listTables function. * Returns a summary for WORKSPACE.md generation. @@ -1715,11 +1661,11 @@ export class WorkspaceVFS { rowCount: t.rowCount, })) } catch (err) { - logger.error('Failed to materialize tables; refusing to serve an incomplete VFS', { + logger.warn('Failed to materialize tables', { workspaceId, error: toError(err).message, }) - throw err + return [] } } @@ -1736,7 +1682,6 @@ export class WorkspaceVFS { const files = await listWorkspaceFiles(workspaceId, { folders, includeReservedSystemFiles: true, - throwOnError: true, }) for (const folder of folders) { if ( @@ -1767,7 +1712,6 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, - updatedAt: file.updatedAt, }) ) } @@ -1853,11 +1797,11 @@ export class WorkspaceVFS { folderPath: f.folderPath ?? null, })) } catch (err) { - logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { + logger.warn('Failed to materialize files', { workspaceId, error: toError(err).message, }) - throw err + return [] } } @@ -1915,11 +1859,6 @@ export class WorkspaceVFS { eq(workflowDeploymentVersion.isActive, true) ) ) - // Match checkNeedsRedeployment/loadDeployedWorkflowState. Historical - // workflows can contain more than one active row, so an unordered - // limit may compare the draft with an older deployment while the UI - // correctly compares against the newest active deployment. - .orderBy(desc(workflowDeploymentVersion.createdAt)) .limit(1) : Promise.resolve([]), db @@ -1975,47 +1914,25 @@ export class WorkspaceVFS { } /** - * Advertise custom tools in the VFS without eagerly loading their code. - * Paths are registered as lazy so glob/WORKSPACE.md see them, but full - * schema+code is fetched only when read (or a grep whose scope touches them). + * Materialize custom tools using the shared listCustomTools function. */ private async materializeCustomTools( workspaceId: string, userId: string ): Promise> { try { - // Metadata only — tool code can be large; keep it out of the eager map. - // Visibility matches listCustomTools: workspace tools + legacy user-owned. - const toolRows = await db - .select({ - id: customToolsTable.id, - title: customToolsTable.title, - }) - .from(customToolsTable) - .where( - or( - eq(customToolsTable.workspaceId, workspaceId), - and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) - ) - ) - .orderBy(desc(customToolsTable.createdAt)) + const toolRows = await listCustomTools({ userId, workspaceId }) for (const tool of toolRows) { const safeName = sanitizeName(tool.title) - const toolId = tool.id - const load = async () => { - const full = await getCustomToolById({ toolId, userId, workspaceId }) - if (!full) return null - return serializeCustomTool({ - id: full.id, - title: full.title, - schema: full.schema, - code: full.code, - }) - } - // Legacy alias + canonical agent/ path — each resolves independently on read. - this.registerLazy(`custom-tools/${safeName}.json`, load) - this.registerLazy(`agent/custom-tools/${safeName}.json`, load) + const serialized = serializeCustomTool({ + id: tool.id, + title: tool.title, + schema: tool.schema, + code: tool.code, + }) + this.files.set(`custom-tools/${safeName}.json`, serialized) + this.files.set(`agent/custom-tools/${safeName}.json`, serialized) } return toolRows.map((t) => ({ id: t.id, name: t.title })) @@ -2114,39 +2031,26 @@ export class WorkspaceVFS { } /** - * Advertise workspace skills in the VFS without eagerly loading their bodies. - * Paths are registered as lazy so glob/WORKSPACE.md see them, but full content - * is fetched only when read (or a grep whose scope touches the path) resolves them. + * Materialize workspace skills using the shared listSkills function. */ private async materializeSkills( workspaceId: string ): Promise> { try { - // Metadata only — skill bodies can be large; keep them out of the eager map. - const skillRows = await db - .select({ - id: skillTable.id, - name: skillTable.name, - description: skillTable.description, - }) - .from(skillTable) - .where(eq(skillTable.workspaceId, workspaceId)) - .orderBy(desc(skillTable.createdAt)) + const skillRows = await listSkills({ workspaceId, includeBuiltins: false }) for (const s of skillRows) { const safeName = sanitizeName(s.name) - const skillId = s.id - this.registerLazy(`agent/skills/${safeName}.json`, async () => { - const full = await getSkillById({ skillId, workspaceId }) - if (!full) return null - return serializeSkill({ - id: full.id, - name: full.name, - description: full.description, - content: full.content, - createdAt: full.createdAt, + this.files.set( + `agent/skills/${safeName}.json`, + serializeSkill({ + id: s.id, + name: s.name, + description: s.description, + content: s.content, + createdAt: s.createdAt, }) - }) + ) } return skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })) @@ -2461,7 +2365,6 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, - updatedAt: file.updatedAt, }) ) } diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts index 9676a30f195..1d3b3b10cb9 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.test.ts @@ -71,7 +71,6 @@ describe('HostedKeyRateLimiter', () => { process.env.EXA_API_KEY_1 = 'test-key-1' process.env.EXA_API_KEY_2 = 'test-key-2' process.env.EXA_API_KEY_3 = 'test-key-3' - process.env.EXA_API_KEY = undefined }) afterEach(() => { @@ -88,7 +87,6 @@ describe('HostedKeyRateLimiter', () => { mockAdapter.consumeTokens.mockResolvedValue(allowedResult) process.env.EXA_API_KEY_COUNT = undefined - process.env.EXA_API_KEY = undefined process.env.EXA_API_KEY_1 = undefined process.env.EXA_API_KEY_2 = undefined process.env.EXA_API_KEY_3 = undefined @@ -104,28 +102,6 @@ describe('HostedKeyRateLimiter', () => { expect(result.error).toContain('No hosted keys configured') }) - it('uses a singular hosted key when no numbered pool count is configured', async () => { - mockAdapter.consumeTokens.mockResolvedValue({ - allowed: true, - tokensRemaining: 9, - resetAt: new Date(Date.now() + 60000), - } satisfies ConsumeResult) - process.env.EXA_API_KEY_COUNT = undefined - process.env.EXA_API_KEY = 'singular-test-key' - - const result = await rateLimiter.acquireKey( - testProvider, - envKeyPrefix, - perRequestRateLimit, - 'workspace-1' - ) - - expect(result.success).toBe(true) - expect(result.key).toBe('singular-test-key') - expect(result.envVarName).toBe('EXA_API_KEY') - expect(result.keyIndex).toBe(0) - }) - it('should rate limit billing actor when wait exceeds the queue cap', async () => { // resetAt past the 5-minute cap forces the wait loop to bail immediately. const rateLimitedResult: ConsumeResult = { diff --git a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts index 2638a385b21..d199ed9e2c2 100644 --- a/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/hosted-key/hosted-key-rate-limiter.ts @@ -79,17 +79,11 @@ function interruptibleSleep(ms: number, signal?: AbortSignal): Promise { } /** - * Resolves env var names for a hosted-key prefix. Numbered pools use a - * `{PREFIX}_COUNT` env var. Deployments that still provide one legacy singular - * `{PREFIX}` key remain supported when no count is configured. + * Resolves env var names for a numbered key prefix using a `{PREFIX}_COUNT` env var. + * E.g. with `EXA_API_KEY_COUNT=5`, returns `['EXA_API_KEY_1', ..., 'EXA_API_KEY_5']`. */ function resolveEnvKeys(prefix: string): string[] { - const countValue = process.env[`${prefix}_COUNT`] - if (countValue === undefined) { - return process.env[prefix] ? [prefix] : [] - } - - const count = Number.parseInt(countValue, 10) + const count = Number.parseInt(process.env[`${prefix}_COUNT`] || '0', 10) const names: string[] = [] for (let i = 1; i <= count; i++) { names.push(`${prefix}_${i}`) @@ -278,8 +272,7 @@ export class HostedKeyRateLimiter { * back to `MAX_QUEUE_WAIT_MS`. On exhaustion the call returns today's 429 result. The * ticket is removed from the queue on exit regardless of success or failure. * - * @param envKeyPrefix - Env var prefix (e.g. 'EXA_API_KEY'). Uses a numbered - * pool via `{prefix}_COUNT`, or the singular prefix when no count is set. + * @param envKeyPrefix - Env var prefix (e.g. 'EXA_API_KEY'). Keys resolved via `{prefix}_COUNT`. * @param billingActorId - The billing actor (typically workspace ID) to rate limit against * @param signal - Optional execution `AbortSignal`; bounds the queue wait to the run's budget. */ diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 1298219df66..1c6d5ff2d87 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -1,9 +1,8 @@ import { db } from '@sim/db' -import { credential, pendingCredentialDraft, user } from '@sim/db/schema' +import { pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, lt } from 'drizzle-orm' -import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') @@ -17,63 +16,29 @@ export async function createConnectDraft(params: { userId: string workspaceId: string providerId: string - /** Reconnect only: the existing credential the callback should rebind instead of creating a new one. */ - credentialId?: string - /** Reconnect only: the credential's actual name, so audit records stay accurate. */ - displayName?: string }): Promise { - const { userId, workspaceId, providerId, credentialId } = params - - let displayName = params.displayName - if (!displayName) { - const service = getAllOAuthServices().find((s) => s.providerId === providerId) - const serviceName = service?.name ?? providerId - - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } - - // Auto-number against existing workspace credentials so repeat connects for - // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) + const { userId, workspaceId, providerId } = params + const service = getAllOAuthServices().find((candidate) => candidate.providerId === providerId) + + let displayName = service?.name ?? providerId + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (row?.name) { + displayName = `${row.name}'s ${displayName}` } - - displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) + } catch { + // Fall back to the service name. } const now = new Date() const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + await db .delete(pendingCredentialDraft) .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) + await db .insert(pendingCredentialDraft) .values({ @@ -82,7 +47,6 @@ export async function createConnectDraft(params: { workspaceId, providerId, displayName, - credentialId: credentialId ?? null, expiresAt, createdAt: now, }) @@ -92,16 +56,8 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { displayName, expiresAt, createdAt: now }, }) - logger.info('Created OAuth connect credential draft', { - userId, - workspaceId, - providerId, - credentialId: credentialId ?? null, - }) + logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) } diff --git a/apps/sim/lib/credentials/display-name.test.ts b/apps/sim/lib/credentials/display-name.test.ts deleted file mode 100644 index 36d23dfc8b9..00000000000 --- a/apps/sim/lib/credentials/display-name.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - DISPLAY_NAME_MAX_LENGTH, - defaultCredentialDisplayName, -} from '@/lib/credentials/display-name' - -const NONE: ReadonlySet = new Set() - -describe('defaultCredentialDisplayName', () => { - it("produces {Name}'s {Service} when the user name is known", () => { - expect(defaultCredentialDisplayName('Justin', 'Gmail', NONE)).toBe("Justin's Gmail") - }) - - it('trims surrounding whitespace from the user name', () => { - expect(defaultCredentialDisplayName(' Justin ', 'Gmail', NONE)).toBe("Justin's Gmail") - }) - - it.each([null, undefined, '', ' '])('falls back to My {Service} for user name %j', (name) => { - expect(defaultCredentialDisplayName(name, 'Gmail', NONE)).toBe('My Gmail') - }) - - it('appends " 2" when the base name is taken', () => { - const taken = new Set(["justin's gmail"]) - expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 2") - }) - - it('skips taken numbered names and picks the next free slot', () => { - const taken = new Set(["justin's gmail", "justin's gmail 2", "justin's gmail 3"]) - expect(defaultCredentialDisplayName('Justin', 'Gmail', taken)).toBe("Justin's Gmail 4") - }) - - it('compares collisions case-insensitively', () => { - const taken = new Set(["justin's gmail"]) - expect(defaultCredentialDisplayName('JUSTIN', 'Gmail', taken)).toBe("JUSTIN's Gmail 2") - }) - - it('numbers the My {Service} fallback on collision too', () => { - const taken = new Set(['my gmail']) - expect(defaultCredentialDisplayName(null, 'Gmail', taken)).toBe('My Gmail 2') - }) - - it('truncates a long user name so name + suffix + disambiguator fit the max length', () => { - const longName = 'x'.repeat(400) - const result = defaultCredentialDisplayName(longName, 'Gmail', NONE) - - expect(result.endsWith("'s Gmail")).toBe(true) - expect(result.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) - - const taken = new Set([result.toLowerCase()]) - const numbered = defaultCredentialDisplayName(longName, 'Gmail', taken) - expect(numbered).toBe(`${result} 2`) - expect(numbered.length).toBeLessThanOrEqual(DISPLAY_NAME_MAX_LENGTH) - }) -}) diff --git a/apps/sim/lib/credentials/display-name.ts b/apps/sim/lib/credentials/display-name.ts deleted file mode 100644 index 9b946f31e56..00000000000 --- a/apps/sim/lib/credentials/display-name.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */ -export const DISPLAY_NAME_MAX_LENGTH = 255 - -/** - * Reserved tail budget when truncating the username so the auto-numbering - * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}. - */ -const COLLISION_SUFFIX_RESERVATION = 5 - -/** Upper bound for the auto-numbering search — pathological if ever reached. */ -const MAX_COLLISION_INDEX = 10000 - -/** - * Default credential display name. Produces `"{Name}'s {Service}"` when the - * user's name is known, falling back to `"My {Service}"` otherwise. The - * username is truncated so the full string (including any auto-numbering - * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}. - * - * When the base name collides with an existing credential in `takenNames`, - * `" 2"`, `" 3"`, ... are appended until an unused name is found. `takenNames` - * must contain lowercased names; comparison is case-insensitive to match the - * duplicate-detection in the connect modal. - */ -export function defaultCredentialDisplayName( - userName: string | null | undefined, - serviceName: string, - takenNames: ReadonlySet -): string { - const trimmed = userName?.trim() - let base: string - if (trimmed) { - const suffix = `'s ${serviceName}` - const nameBudget = Math.max( - 0, - DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION - ) - const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed - base = `${safeName}${suffix}` - } else { - base = `My ${serviceName}` - } - - if (!takenNames.has(base.toLowerCase())) return base - for (let n = 2; n < MAX_COLLISION_INDEX; n++) { - const candidate = `${base} ${n}` - if (!takenNames.has(candidate.toLowerCase())) return candidate - } - return base -} diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index a26a190f126..4e7900ded35 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -63,7 +63,6 @@ export interface PerformCredentialResult { providerErrorCode?: string workspaceId?: string updatedFields?: string[] - previousDisplayName?: string } export async function performUpdateCredential( @@ -224,12 +223,7 @@ export async function performUpdateCredential( request: params.request, }) - return { - success: true, - workspaceId: access.credential.workspaceId, - updatedFields, - previousDisplayName: access.credential.displayName, - } + return { success: true, workspaceId: access.credential.workspaceId, updatedFields } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { return { diff --git a/apps/sim/lib/execution/e2b.test.ts b/apps/sim/lib/execution/e2b.test.ts index 0bc92f095f2..738d1fdfff0 100644 --- a/apps/sim/lib/execution/e2b.test.ts +++ b/apps/sim/lib/execution/e2b.test.ts @@ -88,28 +88,6 @@ describe('e2b sandbox inputs', () => { expect(fetchCall?.[1].user).toBe('root') }) - it('passes the requested timeout to shell execution and returns timeout failures', async () => { - mockCommandsRun.mockRejectedValueOnce( - Object.assign(new Error('Execution timed out'), { - stderr: 'Execution timed out', - exitCode: 1, - }) - ) - - const result = await executeShellInE2B({ - code: 'sleep 60', - envs: {}, - timeoutMs: 7000, - }) - - expect(mockCommandsRun).toHaveBeenCalledWith( - 'sleep 60', - expect.objectContaining({ timeoutMs: 7000 }) - ) - expect(result.error).toContain('Execution timed out') - expect(mockKill).toHaveBeenCalled() - }) - it('throws a clear error and kills the sandbox when the fetch fails', async () => { mockCommandsRun.mockRejectedValueOnce(new Error('curl: (22) 403')) @@ -128,171 +106,3 @@ describe('e2b sandbox inputs', () => { expect(mockRunCode).not.toHaveBeenCalled() }) }) - -describe('e2b result marker extraction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCreate.mockResolvedValue({ - sandboxId: 'sb_1', - files: { write: mockFilesWrite }, - commands: { run: mockCommandsRun }, - runCode: mockRunCode, - kill: mockKill, - }) - mockCommandsRun.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }) - }) - - it('parses the result marker from a single stdout entry', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: ['before\n', `__SIM_RESULT__=${JSON.stringify({ ok: true })}\n`] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual({ ok: true }) - expect(res.stdout).toBe('before') - }) - - it('reassembles a marker line split across stream chunks (large single-line result)', async () => { - const payload = 'x'.repeat(50_000) - const markerLine = `__SIM_RESULT__=${JSON.stringify(payload)}\n` - // The kernel splits one long line across several stream messages; each - // chunk is NOT newline-terminated except the last. - const chunks = [ - 'log line\n', - markerLine.slice(0, 20_000), - markerLine.slice(20_000, 40_000), - markerLine.slice(40_000), - ] - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: chunks }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toBe(payload) - expect(res.stdout).toBe('log line') - }) - - it('returns an error instead of a truncated fragment when the marker payload is corrupted', async () => { - // A genuinely broken payload (e.g. the tail chunk was lost entirely). - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: [`__SIM_RESULT__="${'x'.repeat(100)}\n`] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toMatch(/corrupted in transport/) - expect(res.result).toBeNull() - }) - - it('parses the shell marker and falls back to the raw string for non-JSON payloads', async () => { - mockCommandsRun.mockResolvedValueOnce({ - stdout: `hello\n__SIM_RESULT__=${JSON.stringify([1, 2])}\n`, - stderr: '', - exitCode: 0, - }) - const ok = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) - expect(ok.error).toBeUndefined() - expect(ok.result).toEqual([1, 2]) - expect(ok.stdout).toBe('hello') - - // Shell markers are user-authored (`echo "__SIM_RESULT__=$STATUS"`), so a - // plain non-JSON value is a string result, never a transport error. - mockCommandsRun.mockResolvedValueOnce({ - stdout: '__SIM_RESULT__=ok\n', - stderr: '', - exitCode: 0, - }) - const plain = await executeShellInE2B({ code: 'x', envs: {}, timeoutMs: 1000 }) - expect(plain.error).toBeUndefined() - expect(plain.result).toBe('ok') - }) - - it('takes the LAST marker line so user-printed marker lines cannot shadow the real result', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { - stdout: [ - '__SIM_RESULT__=user-debug-junk\n', - `__SIM_RESULT__=${JSON.stringify({ real: true })}\n`, - ], - }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual({ real: true }) - expect(res.stdout).not.toContain('__SIM_RESULT__') - }) - - it('finds a marker that landed on stderr', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { - stdout: ['regular output\n'], - stderr: [`__SIM_RESULT__=${JSON.stringify([1])}\n`], - }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.error).toBeUndefined() - expect(res.result).toEqual([1]) - expect(res.stdout).not.toContain('__SIM_RESULT__') - }) - - it('keeps separate print lines intact (chunks concatenated verbatim, not newline-joined)', async () => { - mockRunCode.mockResolvedValue({ - error: null, - text: '', - logs: { stdout: ['a\n', 'b\n'], stderr: ['warn\n'] }, - results: [], - }) - - const res = await executeInE2B({ - code: 'x', - language: CodeLanguage.Python, - timeoutMs: 1000, - }) - - expect(res.result).toBeNull() - expect(res.stdout).toBe('a\nb\n\nwarn\n') - }) -}) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 0f9f2e3a553..21c50af09b5 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -140,59 +140,6 @@ async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise< return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) } -/** - * Marker prefix for the serialized code result printed to stdout. Emitters - * (the wrapper builders in the function-execute route) interpolate this - * constant so producer and parser cannot drift. - */ -export const SIM_RESULT_PREFIX = '__SIM_RESULT__=' - -/** - * Extracts the `__SIM_RESULT__=` marker line from stdout and parses its JSON - * payload. Takes the LAST marker line: the wrapper prints its marker after all - * user output, so an earlier user-printed line with the same prefix (debug - * output, a grepped log) never shadows the real result. `parseFailed` means - * the last marker's payload was not valid JSON — `rawPayload` carries it so - * callers whose markers are user-authored (shell) can fall back to the plain - * string, while wrapper-backed callers treat it as transport corruption. - */ -function extractSimResult(stdout: string): { - result: unknown - cleanedStdout: string - parseFailed: boolean - rawPayload?: string -} { - const lines = stdout.split('\n') - let markerIndex = -1 - for (let i = lines.length - 1; i >= 0; i--) { - if (lines[i].startsWith(SIM_RESULT_PREFIX)) { - markerIndex = i - break - } - } - if (markerIndex === -1) { - return { result: null, cleanedStdout: stdout, parseFailed: false } - } - const rawPayload = lines[markerIndex].slice(SIM_RESULT_PREFIX.length) - let result: unknown = null - let parseFailed = false - try { - result = JSON.parse(rawPayload) - } catch { - parseFailed = true - } - const filteredLines = lines.filter((l) => !l.startsWith(SIM_RESULT_PREFIX)) - if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { - filteredLines.pop() - } - return { result, cleanedStdout: filteredLines.join('\n'), parseFailed, rawPayload } -} - -const SIM_RESULT_CORRUPTED_ERROR = - 'Sandbox result was corrupted in transport (the __SIM_RESULT__ line failed to parse). ' + - "Do not trust or persist this call's output. For large results, write the content to a " + - 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' - function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() const binaryExts = new Set([ @@ -249,6 +196,8 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise l.startsWith(prefix)) + let cleanedStdout = stdout + if (marker) { + const jsonPart = marker.slice(prefix.length) + try { + result = JSON.parse(jsonPart) + } catch { + result = jsonPart + } + const filteredLines = lines.filter((l) => !l.startsWith(prefix)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() } + cleanedStdout = filteredLines.join('\n') } - const result = extraction.result const images: string[] = [] if (execution.results?.length) { @@ -399,13 +348,24 @@ export async function executeShellInE2B( } } - // Shell scripts have no wrapper: any __SIM_RESULT__ line is user-authored - // (e.g. `echo "__SIM_RESULT__=$STATUS"`), so a non-JSON payload is a plain - // string result, not transport corruption. commands.run also accumulates - // output into one string, so the chunk-split corruption cannot occur here. - const extraction = extractSimResult(stdout) - const parsed = extraction.parseFailed ? extraction.rawPayload : extraction.result - const cleanedStdout = extraction.cleanedStdout + let parsed: unknown = null + const prefix = '__SIM_RESULT__=' + const lines = stdout.split('\n') + const marker = lines.find((l) => l.startsWith(prefix)) + let cleanedStdout = stdout + if (marker) { + const jsonPart = marker.slice(prefix.length) + try { + parsed = JSON.parse(jsonPart) + } catch { + parsed = jsonPart + } + const filteredLines = lines.filter((l) => !l.startsWith(prefix)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() + } + cleanedStdout = filteredLines.join('\n') + } const exportedFiles: Record = {} for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 6886590acbb..0d7b113c825 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -23,6 +23,7 @@ import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -214,6 +215,7 @@ export async function executeInboxTask(taskId: string): Promise { attachmentResult, workspaceContext, integrationTools, + userSkillTool, userPermission, billingAttribution, entitlements, @@ -221,6 +223,7 @@ export async function executeInboxTask(taskId: string): Promise { fetchAttachments(), generateWorkspaceContext(ws.id, userId), buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + buildUserSkillTool(ws.id), getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), computeWorkspaceEntitlements(ws.id, userId), @@ -244,6 +247,7 @@ export async function executeInboxTask(taskId: string): Promise { workspaceContext, ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), ...(entitlements.length > 0 ? { entitlements } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), diff --git a/apps/sim/lib/mothership/skills.test.ts b/apps/sim/lib/mothership/skills.test.ts new file mode 100644 index 00000000000..67f66e146d2 --- /dev/null +++ b/apps/sim/lib/mothership/skills.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { whereMock } = vi.hoisted(() => ({ whereMock: vi.fn() })) + +vi.mock('@sim/db', () => ({ + db: { select: () => ({ from: () => ({ where: whereMock }) }) }, + skill: { workspaceId: 'workspaceId', name: 'name', description: 'description' }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) })) + +import { buildUserSkillTool, LOAD_USER_SKILL_TOOL_NAME } from './skills' + +describe('buildUserSkillTool', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without a workspace id', async () => { + expect(await buildUserSkillTool('')).toBeNull() + expect(whereMock).not.toHaveBeenCalled() + }) + + it('returns null when the workspace has no user skills', async () => { + whereMock.mockResolvedValue([]) + expect(await buildUserSkillTool('ws-1')).toBeNull() + }) + + it('builds one load_user_skill tool with an enum of workspace skill names', async () => { + whereMock.mockResolvedValue([ + { name: 'posthog-playbook', description: 'PostHog steps' }, + { name: 'brand-voice', description: 'Tone rules' }, + ]) + + const tool = await buildUserSkillTool('ws-1') + + expect(tool?.name).toBe(LOAD_USER_SKILL_TOOL_NAME) + // Must NOT be executeLocally — it dispatches with executor "sim", not the browser client. + expect(tool?.executeLocally).toBeUndefined() + expect(tool?.params).toMatchObject({ mothershipToolKind: 'skill' }) + expect(tool?.input_schema).toMatchObject({ + type: 'object', + properties: { skill_name: { enum: ['posthog-playbook', 'brand-voice'] } }, + required: ['skill_name'], + }) + expect(tool?.description).toContain('posthog-playbook') + expect(tool?.description).toContain('brand-voice') + }) + + it('returns null when the skill query fails', async () => { + whereMock.mockRejectedValue(new Error('db down')) + expect(await buildUserSkillTool('ws-1')).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/skills.ts b/apps/sim/lib/mothership/skills.ts new file mode 100644 index 00000000000..cdb2b42ef95 --- /dev/null +++ b/apps/sim/lib/mothership/skills.ts @@ -0,0 +1,67 @@ +import { db, skill } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { ToolSchema } from '@/lib/copilot/chat/payload' + +const logger = createLogger('MothershipUserSkills') + +export const LOAD_USER_SKILL_TOOL_NAME = 'load_user_skill' + +/** + * Build the single load_user_skill tool that exposes all workspace + * user-created skills to the mothership and its subagents. + * + * User skills live in the `skill` table (builtins are code-only and are treated + * as defaults, so they are excluded here). The tool is a non-deferred, + * sim-executed request-local tool: when the model calls it, Go forwards the + * call and sim resolves the content via resolveSkillContent (see tools/index.ts). + * It is available to all users — there is no super-admin gate, unlike the curated + * mothership_settings tools. + * + * Embedded copilot/internal skills are not handled here: those are autoloaded + * into each agent's system prompt on the Go side and never sent as loadable. + */ +export async function buildUserSkillTool(workspaceId: string): Promise { + if (!workspaceId) return null + + let rows: { name: string; description: string }[] + try { + rows = await db + .select({ name: skill.name, description: skill.description }) + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) + } catch (error) { + logger.error('Failed to load workspace skills for load_user_skill tool', { error, workspaceId }) + return null + } + + if (rows.length === 0) return null + + const skillNames = rows.map((r) => r.name) + const catalog = rows.map((r) => `- ${r.name}: ${r.description}`).join('\n') + + return { + name: LOAD_USER_SKILL_TOOL_NAME, + description: `Load a user-created skill's full instructions. You MUST call this before following a skill: the list below only tells you which skills exist and when each applies — it is NOT the instructions. To use a skill, call load_user_skill with its exact name and follow the content it returns; never act on a skill's name or description alone. Available skills:\n${catalog}`, + input_schema: { + type: 'object', + properties: { + skill_name: { + type: 'string', + description: 'Exact name of the user skill to load.', + enum: skillNames, + }, + }, + required: ['skill_name'], + additionalProperties: false, + }, + // Do NOT set executeLocally: skill content is resolved on the sim backend + // (DB), so Go must dispatch this with executor "sim". executeLocally maps to + // ClientExecutable, which routes the call to the browser client (no handler) + // and the load hangs. mothershipToolKind 'skill' is enough for sim routing. + params: { + mothershipToolKind: 'skill', + mothershipToolName: LOAD_USER_SKILL_TOOL_NAME, + }, + } +} 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 5c658928b55..736e14ab260 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 @@ -123,53 +123,7 @@ describe('trackChatUpload', () => { expectNoWorkspaceStorageAccounting() }) - it('stamps message_id on the UPDATE arm when the birth message is known', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) - - await trackChatUpload( - WORKSPACE_ID, - USER_ID, - CHAT_ID, - S3_KEY, - 'image.png', - 'image/png', - 1024, - 'msg_abc' - ) - - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) - ) - }) - - it('stamps message_id on the fallback INSERT arm and nulls it when omitted', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([]) - - await trackChatUpload( - WORKSPACE_ID, - USER_ID, - CHAT_ID, - S3_KEY, - 'image.png', - 'image/png', - 1024, - 'msg_abc' - ) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ chatId: CHAT_ID, messageId: 'msg_abc' }) - ) - - // Legacy callers without a message id write an explicit NULL ("birth unknown"). - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'wf_existing' }]) - await trackChatUpload(WORKSPACE_ID, USER_ID, CHAT_ID, S3_KEY, 'image.png', 'image/png', 1024) - expect(dbChainMockFns.set).toHaveBeenLastCalledWith( - expect.objectContaining({ messageId: null }) - ) - }) - it('retries metadata naming without workspace storage accounting', async () => { - // 23505 from the partial unique index on (chat_id, display_name) — the case we retry. const displayNameCollision = Object.assign(new Error('duplicate key'), { code: '23505', constraint_name: CHAT_DISPLAY_NAME_INDEX, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 6d31fc10487..5a8e47f72b9 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -452,14 +452,6 @@ export async function ensureWorkspaceFileFolderPath(params: { }): Promise { if (params.pathSegments.length === 0) return null - // Fast path: the whole chain already exists (the common case for repeated - // writes into known folders) — per-segment indexed lookups instead of - // loading the workspace's entire folder table. - const existing = await findWorkspaceFileFolderIdByPath(params.workspaceId, params.pathSegments, { - includeReservedSystemFolders: true, - }) - if (existing) return existing - // Load all active folders once and build a lookup keyed by "name|parentId" // so we can resolve existing segments without a per-segment SELECT. const existingFolders = await db diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts deleted file mode 100644 index 6b032412bd9..00000000000 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => { - const chain = { - from: vi.fn(), - where: vi.fn(), - orderBy: vi.fn(), - } - chain.from.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - return { - chain, - select: vi.fn(() => chain), - } -}) - -vi.mock('@sim/db', () => ({ db: { select: mocks.select } })) - -import { listWorkspaceFiles } from './workspace-file-manager' - -describe('listWorkspaceFiles error handling', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.chain.from.mockReturnValue(mocks.chain) - mocks.chain.where.mockReturnValue(mocks.chain) - mocks.chain.orderBy.mockRejectedValue(new Error('database unavailable')) - mocks.select.mockReturnValue(mocks.chain) - }) - - it('keeps the established best-effort behavior by default', async () => { - await expect(listWorkspaceFiles('workspace-1')).resolves.toEqual([]) - }) - - it('propagates failures when a caller requires an authoritative list', async () => { - await expect(listWorkspaceFiles('workspace-1', { throwOnError: true })).rejects.toThrow( - 'database unavailable' - ) - }) -}) 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 18d26c5f627..e84d4c4dc72 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -83,8 +83,6 @@ interface ListWorkspaceFilesOptions { folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean includeReservedSystemFiles?: boolean - /** Propagate storage errors when an incomplete list would be unsafe. */ - throwOnError?: boolean } /** @@ -593,20 +591,14 @@ export async function trackChatUpload( s3Key: string, fileName: string, contentType: string, - size: number, - messageId?: string + size: number ): Promise<{ displayName: string }> { for (let n = 1; n <= MAX_CHAT_DISPLAY_NAME_RETRIES; n++) { const candidate = suffixedName(fileName, n) try { const updated = await db .update(workspaceFiles) - .set({ - chatId, - messageId: messageId ?? null, - context: 'mothership', - displayName: candidate, - }) + .set({ chatId, context: 'mothership', displayName: candidate }) .where( and( eq(workspaceFiles.key, s3Key), @@ -632,7 +624,6 @@ export async function trackChatUpload( workspaceId, context: 'mothership', chatId, - messageId: messageId ?? null, originalName: fileName, displayName: candidate, contentType, @@ -803,7 +794,6 @@ export async function listWorkspaceFiles( }) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) - if (options?.throwOnError) throw error return [] } } @@ -1202,77 +1192,6 @@ export async function renameWorkspaceFile( } } -/** - * Move and/or rename a workspace file in one atomic row update. Either side - * may be a no-op (same folder = pure rename, same name = pure move); when - * both are unchanged the record is returned untouched. Conflicts at the - * destination throw {@link FileConflictError}. The `renamed`/`moved` flags - * report what actually changed, computed from the same read the update uses. - */ -export async function moveRenameWorkspaceFile(params: { - workspaceId: string - fileId: string - targetFolderId: string | null - newName: string -}): Promise<{ file: WorkspaceFileRecord; renamed: boolean; moved: boolean }> { - const normalizedName = normalizeWorkspaceFileItemName(params.newName.trim(), 'File') - - const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) - if (!fileRecord) { - throw new Error('File not found') - } - - const targetFolderId = await assertWorkspaceFileFolderTarget( - params.workspaceId, - params.targetFolderId - ) - const currentFolderId = fileRecord.folderId ?? null - const renamed = fileRecord.name !== normalizedName - const moved = currentFolderId !== targetFolderId - if (!renamed && !moved) { - return { file: fileRecord, renamed, moved } - } - - const exists = await fileExistsInWorkspace(params.workspaceId, normalizedName, targetFolderId) - if (exists) { - throw new FileConflictError(normalizedName) - } - - let updated: { id: string }[] - try { - updated = await db - .update(workspaceFiles) - .set({ originalName: normalizedName, folderId: targetFolderId, updatedAt: new Date() }) - .where( - and( - eq(workspaceFiles.id, params.fileId), - eq(workspaceFiles.workspaceId, params.workspaceId), - eq(workspaceFiles.context, 'workspace') - ) - ) - .returning({ id: workspaceFiles.id }) - } catch (error: unknown) { - if (getPostgresErrorCode(error) === '23505') { - throw new FileConflictError(normalizedName) - } - throw error - } - - if (updated.length === 0) { - throw new Error('File not found or could not be moved') - } - - return { - file: { - ...fileRecord, - name: normalizedName, - folderId: targetFolderId, - }, - renamed, - moved, - } -} - /** * Soft delete a workspace file. */ diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts deleted file mode 100644 index 683a973714c..00000000000 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' - -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'https://sim.test', -})) - -const genericWebhookConfig = { - type: 'generic_webhook', - name: 'Webhook', - category: 'triggers', - outputs: {}, - subBlocks: [ - { id: 'webhookUrlDisplay', type: 'short-input', readOnly: true, useWebhookUrl: true }, - { id: 'requireAuth', type: 'switch' }, - ], -} - -// Mirrors an integration block (e.g. github) whose trigger-mode webhook-URL display -// fields are namespaced per trigger id and gated on selectedTriggerId. -const multiTriggerConfig = { - type: 'github_v2', - name: 'GitHub', - category: 'tools', - outputs: {}, - subBlocks: [ - { - id: 'webhookUrlDisplay_github_push', - type: 'short-input', - readOnly: true, - useWebhookUrl: true, - condition: { field: 'selectedTriggerId', value: 'github_push' }, - }, - ], -} - -vi.mock('@/blocks/registry', () => ({ - getBlock: (type: string) => - type === 'generic_webhook' - ? genericWebhookConfig - : type === 'github_v2' - ? multiTriggerConfig - : undefined, -})) - -/** - * Builds a minimal one-block workflow whose knowledge block carries the two - * subblock keys `edit_workflow` is allowed to write. - */ -function makeKnowledgeWorkflow(tagFiltersValue: unknown) { - return { - blocks: { - 'kb-1': { - id: 'kb-1', - type: 'knowledge', - name: 'Knowledge 1', - position: { x: 0, y: 0 }, - enabled: true, - outputs: {}, - subBlocks: { - operation: { id: 'operation', type: 'dropdown', value: 'search' }, - tagFilters: { id: 'tagFilters', type: 'knowledge-tag-filters', value: tagFiltersValue }, - documentTags: { - id: 'documentTags', - type: 'document-tag-entry', - value: JSON.stringify([{ id: 't1', tagName: 'Team' }]), - }, - }, - }, - }, - edges: [], - loops: {}, - parallels: {}, - } as unknown as WorkflowState -} - -describe('sanitizeForCopilot knowledge tag subblocks', () => { - // Regression: these keys were stripped, which made them write-only for the agent -- - // edit_workflow could set a tag filter but the agent read back an absent field and - // cleared the user's filter on the next edit. - it('retains tagFilters so the agent can read back what edit_workflow writes', () => { - const value = JSON.stringify([ - { id: 'f1', tagName: 'Department', tagSlot: 'tag1', tagValue: 'it' }, - ]) - - const result = sanitizeForCopilot(makeKnowledgeWorkflow(value)) - const inputs = result.blocks['kb-1'].inputs - - expect(inputs?.tagFilters).toBe(value) - }) - - it('retains documentTags alongside tagFilters', () => { - const result = sanitizeForCopilot(makeKnowledgeWorkflow(JSON.stringify([]))) - const inputs = result.blocks['kb-1'].inputs - - expect(inputs?.documentTags).toBeDefined() - }) - - it('still omits the key when no filter is set, so absent means unset', () => { - const result = sanitizeForCopilot(makeKnowledgeWorkflow(null)) - const inputs = result.blocks['kb-1'].inputs - - expect(inputs).not.toHaveProperty('tagFilters') - }) -}) - -/** Builds a one-block workflow for webhook-URL synthesis tests. */ -function makeSingleBlockWorkflow(blockId: string, block: Record): WorkflowState { - return { - blocks: { [blockId]: { id: blockId, position: { x: 0, y: 0 }, outputs: {}, ...block } }, - edges: [], - loops: {}, - parallels: {}, - } as unknown as WorkflowState -} - -describe('sanitizeForCopilot webhook trigger URL', () => { - // Regression: the webhook URL only existed as a UI-computed display field, so the - // copilot could not tell users where to point their external service. - it('synthesizes the read-only webhook URL from the block id for a generic webhook trigger', () => { - const result = sanitizeForCopilot( - makeSingleBlockWorkflow('hook-1', { - type: 'generic_webhook', - name: 'Webhook 1', - enabled: true, - subBlocks: { requireAuth: { id: 'requireAuth', type: 'switch', value: true } }, - }) - ) - - expect(result.blocks['hook-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( - 'https://sim.test/api/webhooks/trigger/hook-1' - ) - }) - - it('prefers the stored triggerPath over the block id', () => { - const result = sanitizeForCopilot( - makeSingleBlockWorkflow('hook-1', { - type: 'generic_webhook', - name: 'Webhook 1', - enabled: true, - subBlocks: { triggerPath: { id: 'triggerPath', type: 'short-input', value: 'my-path' } }, - }) - ) - - expect(result.blocks['hook-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( - 'https://sim.test/api/webhooks/trigger/my-path' - ) - }) - - it('does not synthesize a URL for non-trigger blocks', () => { - const result = sanitizeForCopilot(makeKnowledgeWorkflow(null)) - - expect(result.blocks['kb-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) - }) - - it('synthesizes a URL for an integration block whose selected trigger is webhook-based', () => { - const result = sanitizeForCopilot( - makeSingleBlockWorkflow('gh-1', { - type: 'github_v2', - name: 'GitHub 1', - enabled: true, - triggerMode: true, - subBlocks: { - selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_push' }, - }, - }) - ) - - expect(result.blocks['gh-1'].inputs?.[TRIGGER_WEBHOOK_URL_FIELD]).toBe( - 'https://sim.test/api/webhooks/trigger/gh-1' - ) - }) - - it('omits the URL when the selected trigger has no webhook-URL field', () => { - const result = sanitizeForCopilot( - makeSingleBlockWorkflow('gh-1', { - type: 'github_v2', - name: 'GitHub 1', - enabled: true, - triggerMode: true, - subBlocks: { - selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_poller' }, - }, - }) - ) - - expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) - }) - - it('omits the URL when the integration block is not in trigger mode', () => { - const result = sanitizeForCopilot( - makeSingleBlockWorkflow('gh-1', { - type: 'github_v2', - name: 'GitHub 1', - enabled: true, - subBlocks: { - selectedTriggerId: { id: 'selectedTriggerId', type: 'dropdown', value: 'github_push' }, - }, - }) - ) - - expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD) - }) -}) diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..04802b76e49 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -1,15 +1,8 @@ import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object' import type { Edge } from 'reactflow' -import { getBaseUrl } from '@/lib/core/utils/urls' import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' -import { - buildSubBlockValues, - evaluateSubBlockCondition, -} from '@/lib/workflows/subblocks/visibility' -import { getBlock } from '@/blocks/registry' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants' /** * Sanitized workflow state for copilot (removes all UI-specific data) @@ -261,13 +254,6 @@ function isToolInput(value: unknown): value is ToolInput { /** * Sanitize subblocks by removing null values and simplifying structure * Maps each subblock key directly to its value instead of the full object - * - * @remarks - * `tagFilters` and `documentTags` are deliberately retained. This is the copilot's read - * view of workflow state, and `edit_workflow` can write both keys, so dropping them here - * makes the field write-only: the agent reads back an absent field and clears the user's - * filter on the next edit. Redaction for shared/exported workflows is a separate concern, - * already handled by `sanitizeWorkflowForSharing`. */ function sanitizeSubBlocks( subBlocks: BlockState['subBlocks'] @@ -324,51 +310,17 @@ function sanitizeSubBlocks( return } + // Skip knowledge base tag filters and document tags (workspace-specific data) + if (key === 'tagFilters' || key === 'documentTags') { + return + } + sanitized[key] = subBlock.value }) return sanitized } -/** - * Resolves the public webhook URL for a block acting as a webhook trigger, or null - * for any other block. Mirrors the UI derivation (`useWebhookManagement`): - * `{baseUrl}/api/webhooks/trigger/{triggerPath || blockId}`. - * - * The webhook URL only ever exists as a UI-computed display field - * (`webhookUrlDisplay`, never persisted), which left the copilot unable to tell - * users where to point their external service. This surfaces it in the copilot's - * read view as the read-only {@link TRIGGER_WEBHOOK_URL_FIELD} input — derived at - * read time, never stored, and rejected on write by `edit_workflow` validation. - */ -function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string | null { - const blockConfig = getBlock(block.type) - if (!blockConfig) return null - - const actsAsTrigger = blockConfig.category === 'triggers' || block.triggerMode === true - if (!actsAsTrigger) return null - - // A webhook-URL display subblock (`useWebhookUrl`) marks a webhook-based trigger. - // Multi-trigger blocks namespace one per trigger id, each gated by a condition on - // selectedTriggerId — only count a field active for the current values, so a block - // configured with a polling trigger doesn't advertise a webhook URL. - const values = buildSubBlockValues(block.subBlocks || {}) - const hasActiveWebhookUrlField = blockConfig.subBlocks.some( - (sb) => sb.useWebhookUrl === true && evaluateSubBlockCondition(sb.condition, values) - ) - if (!hasActiveWebhookUrlField) return null - - const triggerPath = block.subBlocks?.triggerPath?.value - const path = typeof triggerPath === 'string' && triggerPath.length > 0 ? triggerPath : blockId - try { - return `${getBaseUrl()}/api/webhooks/trigger/${path}` - } catch { - // getBaseUrl throws when NEXT_PUBLIC_APP_URL is unset; omit the field rather - // than fail the whole state read. - return null - } -} - /** * Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else) * Uses 0-indexed numbering for else-if conditions @@ -570,11 +522,6 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { } else { // For regular blocks, sanitize subBlocks inputs = sanitizeSubBlocks(block.subBlocks) - - const webhookUrl = resolveTriggerWebhookUrl(blockId, block) - if (webhookUrl) { - inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl - } } // Check if this is a loop or parallel (has children) diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts index 93d0c552267..cd61dad4abf 100644 --- a/apps/sim/lib/workflows/skills/operations.test.ts +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -37,8 +37,8 @@ describe('listSkills includeBuiltins', () => { expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) }) - // The mothership skill inventory passes includeBuiltins: false so it never sees - // the code-only template skills. + // The mothership skill registry passes includeBuiltins: false so it never sees + // the code-only template skills (it loads workspace skills via load_user_skill). it('excludes builtin template skills when includeBuiltins is false', async () => { orderByMock.mockResolvedValue([ { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 7e0fcfb4c2f..361ef667178 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -36,8 +36,8 @@ function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof ski * real skills do. A workspace skill that shares a built-in's name overrides it. * * Pass `includeBuiltins: false` to return only user-created skills. The - * mothership uses this for the workspace skill inventory it sees, which lists - * only user-created skills and never the code-only templates. + * mothership uses this for the skill registry it sees, since it loads workspace + * skills via load_user_skill and never the code-only templates. */ export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { const dbRows = await db diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index b64fc025a2b..88599d51471 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -533,12 +533,8 @@ export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean { * - `hideWhenEnvSet`: hidden when a specific NEXT_PUBLIC_ env var is truthy * (credential fields hidden when the deployment provides them server-side) */ -export function isSubBlockHidden( - subBlock: SubBlockConfig, - options?: { hosted?: boolean } -): boolean { - const hosted = options?.hosted ?? isHosted - if (subBlock.hideWhenHosted && hosted) return true +export function isSubBlockHidden(subBlock: SubBlockConfig): boolean { + if (subBlock.hideWhenHosted && isHosted) return true if (subBlock.hideWhenEnvSet && isTruthy(getEnv(subBlock.hideWhenEnvSet))) return true return false } diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index ce11157ccb7..30379de8031 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -5,7 +5,6 @@ import { bulkArchiveWorkspaceFileItems, createWorkspaceFileFolder, FileConflictError, - moveRenameWorkspaceFile, moveWorkspaceFileItems, renameWorkspaceFile, restoreWorkspaceFile, @@ -308,72 +307,6 @@ export async function performRenameWorkspaceFile( } } -export interface PerformMoveRenameWorkspaceFileParams { - workspaceId: string - userId: string - fileId: string - targetFolderId: string | null - newName: string -} - -export interface PerformMoveRenameWorkspaceFileResult { - success: boolean - error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode - file?: WorkspaceFileRecord -} - -export async function performMoveRenameWorkspaceFile( - params: PerformMoveRenameWorkspaceFileParams -): Promise { - const { workspaceId, userId, fileId, targetFolderId, newName } = params - - try { - const { file, renamed, moved } = await moveRenameWorkspaceFile({ - workspaceId, - fileId, - targetFolderId, - newName, - }) - logger.info('Moved/renamed workspace file', { workspaceId, fileId, renamed, moved }) - - if (moved) { - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_MOVED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: file.name, - description: `Moved file "${file.name}"${targetFolderId ? ' to folder' : ' to root'}`, - metadata: { targetFolderId }, - }) - } - if (renamed) { - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: file.name, - description: `Renamed file to "${file.name}"`, - }) - } - - return { success: true, file } - } catch (error) { - logger.error('Failed to move/rename workspace file', { error }) - if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { - return { success: false, error: toError(error).message, errorCode: 'conflict' } - } - if (toError(error).message.includes('not found')) { - return { success: false, error: toError(error).message, errorCode: 'not_found' } - } - return { success: false, error: toError(error).message, errorCode: 'internal' } - } -} - export async function performRestoreWorkspaceFile( params: PerformRestoreWorkspaceFileParams ): Promise { diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 81c7af23352..5e5268bda8c 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -3,8 +3,6 @@ export { type PerformCreateWorkspaceFileFolderResult, type PerformDeleteWorkspaceFileItemsParams, type PerformDeleteWorkspaceFileItemsResult, - type PerformMoveRenameWorkspaceFileParams, - type PerformMoveRenameWorkspaceFileResult, type PerformMoveWorkspaceFileItemsParams, type PerformMoveWorkspaceFileItemsResult, type PerformRenameWorkspaceFileParams, @@ -17,7 +15,6 @@ export { type PerformUpdateWorkspaceFileFolderResult, performCreateWorkspaceFileFolder, performDeleteWorkspaceFileItems, - performMoveRenameWorkspaceFile, performMoveWorkspaceFileItems, performRenameWorkspaceFile, performRestoreWorkspaceFile, diff --git a/apps/sim/providers/trace-enrichment.ts b/apps/sim/providers/trace-enrichment.ts index b69517a7bb0..0d3c3232b28 100644 --- a/apps/sim/providers/trace-enrichment.ts +++ b/apps/sim/providers/trace-enrichment.ts @@ -13,12 +13,6 @@ interface ChatCompletionLike { message?: { content?: string | null tool_calls?: Array | null - reasoning_content?: string | null - reasoning?: string | null - reasoning_details?: Array<{ - text?: string | null - summary?: string | null - } | null> | null } | null finish_reason?: string | null } | null> @@ -132,15 +126,20 @@ function extractChatCompletionsReasoning( message: NonNullable['message'] ): string | undefined { if (!message) return undefined + const msg = message as unknown as { + reasoning_content?: string | null + reasoning?: string | null + reasoning_details?: Array<{ text?: string | null; summary?: string | null } | null> | null + } - if (typeof message.reasoning_content === 'string' && message.reasoning_content.length > 0) { - return message.reasoning_content + if (typeof msg.reasoning_content === 'string' && msg.reasoning_content.length > 0) { + return msg.reasoning_content } - if (typeof message.reasoning === 'string' && message.reasoning.length > 0) { - return message.reasoning + if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0) { + return msg.reasoning } - if (Array.isArray(message.reasoning_details)) { - const joined = message.reasoning_details + if (Array.isArray(msg.reasoning_details)) { + const joined = msg.reasoning_details .map((d) => d?.text ?? d?.summary ?? '') .filter((s): s is string => typeof s === 'string' && s.length > 0) .join('\n') diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts index a46bc93adc1..79d833e1c1a 100644 --- a/apps/sim/stores/panel/types.ts +++ b/apps/sim/stores/panel/types.ts @@ -32,4 +32,3 @@ export type ChatContext = | { kind: 'slash_command'; command: string; label: string } | { kind: 'integration'; blockType: string; label: string } | { kind: 'skill'; skillId: string; label: string } - | { kind: 'mcp'; serverId: string; label: string } diff --git a/apps/sim/tools/hosting.ts b/apps/sim/tools/hosting.ts deleted file mode 100644 index 28261170fbc..00000000000 --- a/apps/sim/tools/hosting.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ToolHostingCondition, ToolHostingPredicate } from '@/tools/types' - -/** - * Defines conditional hosted-key eligibility once for both runtime evaluation - * and the machine-readable VFS auth contract. - */ -export function hostedKeyEnabledWhen

(condition: ToolHostingCondition): ToolHostingPredicate

{ - const predicate = ((params: P) => { - const value = (params as unknown as Record)[condition.field] - if (condition.operator === 'equals') return value === condition.value - return condition.values.includes(value as string | number | boolean | null) - }) as ToolHostingPredicate

- - predicate.condition = condition - return predicate -} diff --git a/apps/sim/tools/image/generate.ts b/apps/sim/tools/image/generate.ts index 32a94ebb165..d100d84d66f 100644 --- a/apps/sim/tools/image/generate.ts +++ b/apps/sim/tools/image/generate.ts @@ -1,5 +1,4 @@ import { FALAI_HOSTED_KEY_MARKUP_MULTIPLIER } from '@/lib/tools/falai-pricing' -import { hostedKeyEnabledWhen } from '@/tools/hosting' import type { ImageGenerationParams, ImageGenerationResponse } from '@/tools/image/types' import type { ToolConfig } from '@/tools/types' @@ -116,11 +115,7 @@ export const imageGenerateTool: ToolConfig({ - field: 'provider', - operator: 'equals', - value: 'falai', - }), + enabled: (params) => params.provider === 'falai', envKeyPrefix: 'FALAI_API_KEY', apiKeyParam: 'apiKey', byokProviderId: 'falai', diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 80dee08c89b..08a40cf79b8 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1002,7 +1002,7 @@ export async function executeTool( const scope = resolveToolScope(params, executionContext) const toolKind: 'skill' | 'custom' | 'mcp' | undefined = - normalizedToolId === 'load_skill' + normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill' ? 'skill' : isCustomTool(normalizedToolId) ? 'custom' @@ -1022,7 +1022,7 @@ export async function executeTool( }) } - if (normalizedToolId === 'load_skill') { + if (normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill') { const skillName = params.skill_name if (!skillName || !scope.workspaceId) { return { diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index 31e1e52c36c..50dbc4d1de7 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -1,30 +1,4 @@ import { functionExecuteTool } from '@/tools/function' -import { - gmailAddLabelV2Tool, - gmailArchiveV2Tool, - gmailCreateLabelV2Tool, - gmailDeleteDraftV2Tool, - gmailDeleteLabelV2Tool, - gmailDeleteV2Tool, - gmailDraftV2Tool, - gmailEditDraftV2Tool, - gmailGetDraftV2Tool, - gmailGetThreadV2Tool, - gmailListDraftsV2Tool, - gmailListLabelsV2Tool, - gmailListThreadsV2Tool, - gmailMarkReadV2Tool, - gmailMarkUnreadV2Tool, - gmailMoveV2Tool, - gmailReadV2Tool, - gmailRemoveLabelV2Tool, - gmailSearchV2Tool, - gmailSendV2Tool, - gmailTrashThreadV2Tool, - gmailUnarchiveV2Tool, - gmailUntrashThreadV2Tool, - gmailUpdateLabelV2Tool, -} from '@/tools/gmail' import { guardrailsValidateTool } from '@/tools/guardrails' import { httpRequestTool } from '@/tools/http' import { @@ -79,38 +53,13 @@ import type { ToolConfig } from '@/tools/types' * next.config.ts) so the local dev server never compiles the full ~247-tool * graph (~2,074 modules) that the shared workspace layout otherwise drags into * every route. Only these tools execute in minimal mode; unset the flag for the - * full set. The slack and gmail v2 sets back the `slack`/`slack_v2` and - * `gmail_v2` blocks kept in `blocks/registry-maps.minimal.ts`. NEVER aliased in - * production. + * full set. Slack tools are included so the Slack block (action + native trigger) + * works end-to-end in minimal dev. NEVER aliased in production. */ export const tools: Record = { http_request: httpRequestTool, function_execute: functionExecuteTool, guardrails_validate: guardrailsValidateTool, - gmail_send_v2: gmailSendV2Tool, - gmail_read_v2: gmailReadV2Tool, - gmail_search_v2: gmailSearchV2Tool, - gmail_draft_v2: gmailDraftV2Tool, - gmail_move_v2: gmailMoveV2Tool, - gmail_mark_read_v2: gmailMarkReadV2Tool, - gmail_mark_unread_v2: gmailMarkUnreadV2Tool, - gmail_archive_v2: gmailArchiveV2Tool, - gmail_unarchive_v2: gmailUnarchiveV2Tool, - gmail_delete_v2: gmailDeleteV2Tool, - gmail_add_label_v2: gmailAddLabelV2Tool, - gmail_remove_label_v2: gmailRemoveLabelV2Tool, - gmail_create_label_v2: gmailCreateLabelV2Tool, - gmail_delete_draft_v2: gmailDeleteDraftV2Tool, - gmail_delete_label_v2: gmailDeleteLabelV2Tool, - gmail_edit_draft_v2: gmailEditDraftV2Tool, - gmail_get_draft_v2: gmailGetDraftV2Tool, - gmail_get_thread_v2: gmailGetThreadV2Tool, - gmail_list_drafts_v2: gmailListDraftsV2Tool, - gmail_list_labels_v2: gmailListLabelsV2Tool, - gmail_list_threads_v2: gmailListThreadsV2Tool, - gmail_trash_thread_v2: gmailTrashThreadV2Tool, - gmail_untrash_thread_v2: gmailUntrashThreadV2Tool, - gmail_update_label_v2: gmailUpdateLabelV2Tool, slack_add_reaction: slackAddReactionTool, slack_archive_conversation: slackArchiveConversationTool, slack_canvas: slackCanvasTool, diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 261ac103a50..a004d61ebd9 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -303,23 +303,6 @@ interface CustomPricing

> { /** Union of all pricing models */ export type ToolHostingPricing

> = PerRequestPricing | CustomPricing

-export type ToolHostingCondition = - | { - field: string - operator: 'equals' - value: string | number | boolean | null - } - | { - field: string - operator: 'one_of' - values: Array - } - -export type ToolHostingPredicate

= ((params: P) => boolean) & { - /** Serializable equivalent of this predicate for VFS consumers. */ - condition?: ToolHostingCondition -} - /** * Configuration for hosted API key support. * When configured, the tool can use Sim's hosted API keys if user doesn't provide their own. @@ -341,20 +324,16 @@ export type ToolHostingPredicate

= ((params: P) => boolean) & { * EXA_API_KEY_5=sk-... * ``` * - * For a single-key deployment, `{envKeyPrefix}` is also supported when no - * `{envKeyPrefix}_COUNT` is configured. - * * Adding more keys only requires updating the count and adding the new env var — * no code changes needed. */ export interface ToolHostingConfig

> { /** Optional predicate for tools where hosted keys only apply to some parameter combinations. */ - enabled?: ToolHostingPredicate

+ enabled?: (params: P) => boolean /** * Env var name prefix for hosted keys. * At runtime, `{envKeyPrefix}_COUNT` is read to determine how many keys exist, - * then `{envKeyPrefix}_1` through `{envKeyPrefix}_N` are resolved. If no count - * is configured, a singular `{envKeyPrefix}` is used when present. + * then `{envKeyPrefix}_1` through `{envKeyPrefix}_N` are resolved. */ envKeyPrefix: string /** The parameter name that receives the API key */ diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index d7126c12b8e..bd1ca98de0d 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -30,16 +30,6 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ 'triggerId', ] -/** - * Synthesized read-only field exposing a webhook trigger block's public URL in the - * copilot's read view of workflow state (see sanitizeForCopilot). The URL is derived - * at read time — it is never persisted — and edit_workflow rejects writes to it. - * - * Deliberately NOT 'webhookUrl': that id is a real user-editable subblock on some - * action blocks (e.g. Vercel's create_webhook target URL). - */ -export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl' - /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. * This prevents runaway errors from continuously executing failing workflows. diff --git a/apps/sim/vitest.setup.ts b/apps/sim/vitest.setup.ts index 1213b48efc8..ca6b4fc3922 100644 --- a/apps/sim/vitest.setup.ts +++ b/apps/sim/vitest.setup.ts @@ -87,19 +87,8 @@ vi.mock('@/blocks/registry', () => ({ subBlocks: [], outputs: {}, })), - getAllBlocks: vi.fn(() => []), + getAllBlocks: vi.fn(() => ({})), getLatestBlock: vi.fn(() => undefined), - getBlockByToolName: vi.fn((toolName: string) => - toolName.startsWith('gmail_') - ? { - name: 'Gmail', - description: 'Gmail integration', - icon: () => null, - subBlocks: [], - outputs: {}, - } - : undefined - ), })) vi.mock('@trigger.dev/sdk', () => ({ diff --git a/bun.lock b/bun.lock index 7e81dd4365f..2cad159bb69 100644 --- a/bun.lock +++ b/bun.lock @@ -3086,7 +3086,7 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -4462,6 +4462,10 @@ "@sim/db/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], + "@sim/emcn/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + + "@sim/workflow-renderer/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], "@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4660,14 +4664,10 @@ "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-openapi/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="], - "fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4822,6 +4822,8 @@ "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], + "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index b9b3d37165c..fedc7d65fec 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -7,7 +7,7 @@ export { CalendarDayCell, type CalendarDayCellProps, } from './calendar/calendar-day-cell' -export { Checkbox, checkboxIconVariants, checkboxVariants } from './checkbox/checkbox' +export { Checkbox } from './checkbox/checkbox' export { Chip, ChipLink, diff --git a/packages/emcn/src/icons/chevron-left.tsx b/packages/emcn/src/icons/chevron-left.tsx deleted file mode 100644 index f79b8280e7f..00000000000 --- a/packages/emcn/src/icons/chevron-left.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { SVGProps } from 'react' - -/** - * ChevronLeft icon component - * @param props - SVG properties including className, fill, etc. - */ -export function ChevronLeft(props: SVGProps) { - return ( - - ) -} diff --git a/packages/emcn/src/icons/chevron-right.tsx b/packages/emcn/src/icons/chevron-right.tsx deleted file mode 100644 index 2f2a591ae87..00000000000 --- a/packages/emcn/src/icons/chevron-right.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { SVGProps } from 'react' - -/** - * ChevronRight icon component - * @param props - SVG properties including className, fill, etc. - */ -export function ChevronRight(props: SVGProps) { - return ( - - ) -} diff --git a/packages/emcn/src/icons/index.ts b/packages/emcn/src/icons/index.ts index 5bbd98df1b5..6cf6d1bbf20 100644 --- a/packages/emcn/src/icons/index.ts +++ b/packages/emcn/src/icons/index.ts @@ -14,8 +14,6 @@ export { Bug } from './bug' export { Calendar } from './calendar' export { Check } from './check' export { ChevronDown } from './chevron-down' -export { ChevronLeft } from './chevron-left' -export { ChevronRight } from './chevron-right' export { CircleAlert } from './circle-alert' export { CircleCheck } from './circle-check' export { CircleInfo } from './circle-info' @@ -88,7 +86,6 @@ export { ShieldCheck } from './shield-check' export { Shuffle } from './shuffle' export { Sim } from './sim' export { Slash } from './slash' -export { Split } from './split' export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' diff --git a/packages/emcn/src/icons/split.tsx b/packages/emcn/src/icons/split.tsx deleted file mode 100644 index aac39588a5c..00000000000 --- a/packages/emcn/src/icons/split.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { SVGProps } from 'react' - -/** - * Split icon component - one path branching into two - * @param props - SVG properties including className, fill, etc. - */ -export function Split(props: SVGProps) { - return ( - - ) -} diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts index 2d9cd96442d..9a9a18b498d 100644 --- a/packages/security/src/hash.ts +++ b/packages/security/src/hash.ts @@ -1,17 +1,10 @@ import { createHash } from 'node:crypto' /** - * Deterministic SHA-256 digest, hex-encoded. Strings hash as UTF-8; binary - * content passes as a Uint8Array/Buffer. Use for indexed lookup of sensitive - * values (e.g. API key hash columns) and content-integrity receipts where the + * Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for + * indexed lookup of sensitive values (e.g. API key hash columns) where the * caller only needs to verify equality without ever reversing the hash. */ -export function sha256Hex(input: string | Uint8Array): string { - const hash = createHash('sha256') - if (typeof input === 'string') { - hash.update(input, 'utf8') - } else { - hash.update(input) - } - return hash.digest('hex') +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex') } diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 67f7727f889..285743741a6 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -91,7 +91,7 @@ function renderRuntimeSchemaModule(catalog: { tools: Record[] } 'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined' const resultSchema = 'resultSchema' in tool ? JSON.stringify(tool.resultSchema ?? null, null, 2) : 'undefined' - lines.push(` ${id}: {`) + lines.push(` [${id}]: {`) lines.push( ` parameters: ${parameters === 'null' ? 'undefined' : parameters.replace(/\n/g, '\n ')},` )