From a8553013cc0c13335a8b7ad66e416680af2bdb23 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 10 Sep 2026 17:52:00 -0700 Subject: [PATCH] fix(search): inject personal integrations into chat prompts --- .../load-search-integrations.test.ts | 161 ++++++++++++++++++ .../application/load-search-integrations.ts | 63 +++++++ apps/sim/lib/copilot/assistant/tool-policy.ts | 1 - .../lib/copilot/generated/tool-catalog-v1.ts | 18 -- .../lib/copilot/generated/tool-schemas-v1.ts | 19 --- .../lib/copilot/request/lifecycle/run.test.ts | 80 +++++++++ apps/sim/lib/copilot/request/lifecycle/run.ts | 14 ++ .../copilot/tool-executor/executor.test.ts | 20 +++ .../sim/lib/copilot/tool-executor/executor.ts | 5 +- .../knowledge/list-integrations.test.ts | 73 -------- .../server/knowledge/list-integrations.ts | 52 ------ apps/sim/lib/copilot/tools/server/router.ts | 2 - apps/sim/lib/copilot/tools/tool-display.ts | 1 - .../lib/slack-search/assistant-stream.test.ts | 7 +- apps/sim/lib/slack-search/assistant-stream.ts | 1 - 15 files changed, 343 insertions(+), 174 deletions(-) create mode 100644 apps/sim/lib/copilot/application/load-search-integrations.test.ts create mode 100644 apps/sim/lib/copilot/application/load-search-integrations.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/list-integrations.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts diff --git a/apps/sim/lib/copilot/application/load-search-integrations.test.ts b/apps/sim/lib/copilot/application/load-search-integrations.test.ts new file mode 100644 index 00000000000..6da78ddac74 --- /dev/null +++ b/apps/sim/lib/copilot/application/load-search-integrations.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authorizeChat, listIntegrations } = vi.hoisted(() => ({ + authorizeChat: vi.fn(), + listIntegrations: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + authorizeOrganizationChatDelegation: { execute: authorizeChat }, +})) +vi.mock('@/lib/knowledge/application/personal-search-integrations', () => ({ + listPersonalSearchIntegrations: { execute: listIntegrations }, +})) + +import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' +import type { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' + +type InventoryPage = Awaited> + +const context = { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + messageId: 'message-1', +} +const emptyPage: InventoryPage = { + connections: [], + available: [], + completedCredentialId: null, + nextCursor: null, +} + +describe('loadCopilotSearchIntegrations', () => { + beforeEach(() => { + vi.clearAllMocks() + authorizeChat.mockResolvedValue(undefined) + listIntegrations.mockResolvedValue(emptyPage) + }) + + it('authorizes the private chat and reads only for the authenticated person and organization', async () => { + expect(await loadCopilotSearchIntegrations(context)).toBe('{"connections":[],"available":[]}') + const principal = authorizeChat.mock.calls[0][0].principal + expect(principal).toMatchObject({ + kind: 'organization_delegated', + serviceId: 'copilot', + subjectUserId: 'person-1', + organizationId: 'org-1', + delegationId: 'message-1', + audience: 'sim:knowledge', + resourceScope: { chatId: 'private-chat-1' }, + }) + expect(listIntegrations).toHaveBeenCalledExactlyOnceWith({ + principal, + input: { organizationId: 'org-1' }, + }) + expect(authorizeChat.mock.invocationCallOrder[0]).toBeLessThan( + listIntegrations.mock.invocationCallOrder[0] + ) + }) + + it('loads every page and preserves account status and exact connection controls', async () => { + const available: InventoryPage['available'][number] = { + name: 'Gmail', + description: 'Personal mail', + target: { type: 'link', provider: 'google-email', connectorType: 'gmail' }, + } + const connection: InventoryPage['connections'][number] = { + name: 'Gmail', + providerId: 'google-email', + connectorType: 'gmail', + connectorId: 'source-1', + knowledgeBaseId: 'kb-1', + description: 'Personal mail', + accounts: [ + { + credentialId: 'account-1', + displayName: 'me@example.com', + status: 'reconnect_needed', + action: { ...available.target, connectorId: 'source-1', credentialId: 'account-1' }, + }, + ], + connectionStatus: 'reconnect_needed', + indexingStatus: 'indexed', + searchableDocuments: 7, + action: null, + } + listIntegrations + .mockResolvedValueOnce({ ...emptyPage, available: [available], nextCursor: 'page-2' }) + .mockResolvedValueOnce({ ...emptyPage, connections: [connection], available: [available] }) + + expect(JSON.parse(await loadCopilotSearchIntegrations(context))).toEqual({ + connections: [connection], + available: [available], + }) + expect(listIntegrations.mock.calls[1][0]).toEqual({ + principal: authorizeChat.mock.calls[0][0].principal, + input: { organizationId: 'org-1', cursor: 'page-2' }, + }) + }) + + it('does not read inventory when private-chat authorization fails', async () => { + authorizeChat.mockRejectedValueOnce(new Error('Chat belongs to another person')) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow( + 'Chat belongs to another person' + ) + expect(listIntegrations).not.toHaveBeenCalled() + }) + + it('fails the turn if a later page cannot be read', async () => { + listIntegrations + .mockResolvedValueOnce({ ...emptyPage, nextCursor: 'page-2' }) + .mockRejectedValueOnce(new Error('Inventory unavailable')) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('Inventory unavailable') + }) + + it('rejects a repeated cursor instead of looping or returning partial inventory', async () => { + listIntegrations.mockResolvedValue({ ...emptyPage, nextCursor: 'page-2' }) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow( + 'pagination did not advance' + ) + expect(listIntegrations).toHaveBeenCalledTimes(2) + }) + + it('bounds page loading when the inventory never ends', async () => { + listIntegrations.mockImplementation(async () => ({ + ...emptyPage, + nextCursor: `page-${listIntegrations.mock.calls.length + 1}`, + })) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('pagination limit') + expect(listIntegrations).toHaveBeenCalledTimes(100) + }) + + it('rejects oversized prompt content instead of silently truncating it', async () => { + listIntegrations.mockResolvedValueOnce({ + ...emptyPage, + available: [ + { + name: 'Gmail', + description: 'x'.repeat(256 * 1024), + target: { type: 'link', provider: 'google-email', connectorType: 'gmail' }, + }, + ], + }) + await expect(loadCopilotSearchIntegrations(context)).rejects.toThrow('prompt size limit') + }) + + it('stops loading when the turn is cancelled between pages', async () => { + const controller = new AbortController() + listIntegrations.mockImplementationOnce(async () => { + controller.abort(new Error('Turn cancelled')) + return { ...emptyPage, nextCursor: 'page-2' } + }) + await expect( + loadCopilotSearchIntegrations({ ...context, signal: controller.signal }) + ).rejects.toThrow('Turn cancelled') + expect(listIntegrations).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/copilot/application/load-search-integrations.ts b/apps/sim/lib/copilot/application/load-search-integrations.ts new file mode 100644 index 00000000000..4627bf7a8b4 --- /dev/null +++ b/apps/sim/lib/copilot/application/load-search-integrations.ts @@ -0,0 +1,63 @@ +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/copilot/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' + +const MAX_INVENTORY_PAGES = 100 +const MAX_INVENTORY_BYTES = 256 * 1024 + +interface SearchIntegrationsContext { + userId: string + organizationId: string + chatId: string + messageId: string + signal?: AbortSignal +} + +type IntegrationInventory = Awaited> + +/** Loads the complete current person's Search inventory for one authenticated chat turn. */ +export async function loadCopilotSearchIntegrations( + context: SearchIntegrationsContext +): Promise { + context.signal?.throwIfAborted() + const principal = createTrustedOrganizationCopilotPrincipal( + { ...context, delegationId: context.messageId }, + { + audience: knowledgeDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + } + ) + await authorizeOrganizationChatDelegation.execute({ principal }) + + const connections: IntegrationInventory['connections'] = [] + const available = new Map() + const cursors = new Set() + let cursor: string | undefined + for (let pageNumber = 0; pageNumber < MAX_INVENTORY_PAGES; pageNumber++) { + context.signal?.throwIfAborted() + const page = await listPersonalSearchIntegrations.execute({ + principal, + input: { organizationId: context.organizationId, ...(cursor ? { cursor } : {}) }, + }) + context.signal?.throwIfAborted() + connections.push(...page.connections) + for (const entry of page.available) { + available.set(JSON.stringify(entry.target), entry) + } + const inventory = JSON.stringify({ connections, available: [...available.values()] }) + if (Buffer.byteLength(inventory) > MAX_INVENTORY_BYTES) { + throw new Error('Search integration inventory exceeds the prompt size limit') + } + if (page.nextCursor === null) return inventory + if (cursors.has(page.nextCursor)) { + throw new Error('Search integration inventory pagination did not advance') + } + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + throw new Error('Search integration inventory exceeds the pagination limit') +} diff --git a/apps/sim/lib/copilot/assistant/tool-policy.ts b/apps/sim/lib/copilot/assistant/tool-policy.ts index 2ee6fe3c3b3..4289ad9660d 100644 --- a/apps/sim/lib/copilot/assistant/tool-policy.ts +++ b/apps/sim/lib/copilot/assistant/tool-policy.ts @@ -3,7 +3,6 @@ import type { ToolMetadata } from '@/tools/metadata' export const ASSISTANT_TOOLS = new Set([ 'search_workspace', 'read_document', - 'list_integrations', 'search_integration_tools', 'call_integration_tool', 'oauth_get_auth_link', diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 614726dfe67..b65bebaf7d1 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -74,7 +74,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_integrations' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -213,7 +212,6 @@ export interface ToolCatalogEntry { | 'knowledge' | 'list_deployment_versions' | 'list_integration_tools' - | 'list_integrations' | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' @@ -3543,21 +3541,6 @@ export const ListIntegrationTools: ToolCatalogEntry = { }, } -export const ListIntegrations: ToolCatalogEntry = { - id: 'list_integrations', - name: 'list_integrations', - route: 'sim', - mode: 'async', - parameters: { - additionalProperties: false, - properties: { - connectorType: { maxLength: 100, minLength: 1, type: 'string' }, - cursor: { maxLength: 1024, minLength: 1, type: 'string' }, - }, - type: 'object', - }, -} - export const ListWorkspaceMcpServers: ToolCatalogEntry = { id: 'list_workspace_mcp_servers', name: 'list_workspace_mcp_servers', @@ -7664,7 +7647,6 @@ export const TOOL_CATALOG: Record = { [Knowledge.id]: Knowledge, [ListDeploymentVersions.id]: ListDeploymentVersions, [ListIntegrationTools.id]: ListIntegrationTools, - [ListIntegrations.id]: ListIntegrations, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e7fb2ccfda7..388d341ae1f 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3474,25 +3474,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - list_integrations: { - parameters: { - additionalProperties: false, - properties: { - connectorType: { - maxLength: 100, - minLength: 1, - type: 'string', - }, - cursor: { - maxLength: 1024, - minLength: 1, - type: 'string', - }, - }, - type: 'object', - }, - resultSchema: undefined, - }, list_workspace_mcp_servers: { parameters: { type: 'object', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index cbc9ccaab7a..5f8f74b27a1 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -15,6 +15,7 @@ const { mockForceFailHungToolCall, mockGetMothershipBaseURL, mockGetMothershipSourceEnvHeaders, + mockLoadCopilotSearchIntegrations, mockPrepareCopilotEnvironmentContext, mockPrepareExecutionContext, mockRunStreamLoop, @@ -29,6 +30,7 @@ const { mockForceFailHungToolCall: vi.fn(), mockGetMothershipBaseURL: vi.fn(), mockGetMothershipSourceEnvHeaders: vi.fn(), + mockLoadCopilotSearchIntegrations: vi.fn(), mockPrepareCopilotEnvironmentContext: vi.fn(), mockPrepareExecutionContext: vi.fn(), mockRunStreamLoop: vi.fn(), @@ -43,6 +45,10 @@ const { }, })) +vi.mock('@/lib/copilot/application/load-search-integrations', () => ({ + loadCopilotSearchIntegrations: mockLoadCopilotSearchIntegrations, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ filterModelSafeWorkspaceFileAttachments: (...args: unknown[]) => mockFilterModelSafeWorkspaceFileAttachments(...args), @@ -172,11 +178,84 @@ describe('runCopilotLifecycle', () => { mockPendingToolWaitBudgetMs.mockImplementation(() => 60_000) mockGetMothershipBaseURL.mockResolvedValue('http://mothership.test') mockGetMothershipSourceEnvHeaders.mockReturnValue({}) + mockLoadCopilotSearchIntegrations.mockResolvedValue('{"connections":[],"available":[]}') mockPrepareCopilotEnvironmentContext.mockResolvedValue({ resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), }) }) + it.each([ + { surface: 'web Search', goRoute: '/api/mothership', interactive: true }, + { surface: 'Slack Search', goRoute: '/api/mothership', interactive: false }, + { surface: 'MCP Search', goRoute: '/api/mothership/execute', interactive: false }, + ])('injects fresh trusted inventory for each $surface turn', async ({ goRoute, interactive }) => { + mockRunStreamLoop.mockResolvedValue(undefined) + const signal = new AbortController().signal + const payload = { + mode: 'assistant', + message: 'Is my email connected?', + messageId: 'message-1', + userId: 'untrusted-person', + organizationId: 'org-1', + workspaceContext: 'stale or untrusted inventory', + } + for (const status of ['not_connected', 'connected']) { + const inventory = JSON.stringify({ + connections: [{ connectionStatus: status }], + available: [], + }) + mockLoadCopilotSearchIntegrations.mockResolvedValueOnce(inventory) + const result = await runCopilotLifecycle(payload, { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + executionId: 'execution-1', + runId: 'run-1', + goRoute, + interactive, + abortSignal: signal, + }) + expect(result.error).toBeUndefined() + const body = JSON.parse(String(mockRunStreamLoop.mock.lastCall?.[1].body)) + expect(body.workspaceContext).toBe(inventory) + expect(mockLoadCopilotSearchIntegrations).toHaveBeenLastCalledWith({ + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + messageId: 'message-1', + signal, + }) + } + expect(mockLoadCopilotSearchIntegrations).toHaveBeenCalledTimes(2) + expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(payload.workspaceContext).toBe('stale or untrusted inventory') + }) + + it('does not call Copilot if the Search inventory cannot be loaded', async () => { + mockLoadCopilotSearchIntegrations.mockRejectedValueOnce(new Error('Inventory unavailable')) + const result = await runCopilotLifecycle( + { mode: 'assistant', message: 'Find my email', messageId: 'message-1' }, + { + userId: 'person-1', + organizationId: 'org-1', + chatId: 'private-chat-1', + } + ) + expect(result).toMatchObject({ success: false, error: 'Inventory unavailable' }) + expect(mockRunStreamLoop).not.toHaveBeenCalled() + }) + + it('keeps workspace Assistant context without loading Search integrations', async () => { + mockRunStreamLoop.mockResolvedValueOnce(undefined) + await runCopilotLifecycle( + { mode: 'assistant', message: 'hello', workspaceContext: 'Workspace inventory' }, + { userId: 'person-1', workspaceId: 'ws-1', chatId: 'chat-1' } + ) + expect(mockLoadCopilotSearchIntegrations).not.toHaveBeenCalled() + const body = JSON.parse(String(mockRunStreamLoop.mock.calls[0][1].body)) + expect(body.workspaceContext).toBe('Workspace inventory') + }) + it('threads trace provenance through server execution context only', async () => { const registry = new ResolvedSecretTraceRegistry() const executionContext: ExecutionContext = { @@ -1638,6 +1717,7 @@ describe('runCopilotLifecycle', () => { expect(billingRequestId).not.toBe('caller-controlled') expect(mockRunStreamLoop).toHaveBeenCalledTimes(2) + expect(mockLoadCopilotSearchIntegrations).toHaveBeenCalledTimes(owner.organizationId ? 1 : 0) for (const call of mockRunStreamLoop.mock.calls) { const body = JSON.parse(String(call[1].body)) expect(body).toMatchObject( diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 302456bc50e..d9b090fd48a 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -13,6 +13,7 @@ import { createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/billing-attribution' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' @@ -398,6 +399,19 @@ export async function runCopilotLifecycle( try { await ensureModelEgressRegistry(execContext, lifecycleOptions) + if (organizationId && goRoute !== '/api/tools/resume') { + if (!chatId) throw new Error('Search integration context requires a private chat ID') + requestPayload = { + ...requestPayload, + workspaceContext: await loadCopilotSearchIntegrations({ + userId, + organizationId, + chatId, + messageId: payloadMsgId, + signal: lifecycleOptions.abortSignal, + }), + } + } const modelSafeRequestPayload = await filterInitialCopilotAttachmentsForModel( requestPayload, lifecycleOptions.workspaceId diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 9c4be645f05..132d406aeb3 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -88,6 +88,26 @@ describe('copilot tool executor fallback', () => { } ) + it.each([{ organizationId: 'org-1' }, { workspaceId: 'workspace-1' }])( + 'rejects the retired inventory tool before dispatch for %j', + async (scope) => { + const handler = vi.fn() + registerHandler('list_integrations', handler) + const result = await executeTool( + 'list_integrations', + {}, + { + userId: 'user-1', + requestMode: 'assistant', + ...scope, + } + ) + expect(result.success).toBe(false) + expect(handler).not.toHaveBeenCalled() + expect(executeAppTool).not.toHaveBeenCalled() + } + ) + it.each([ ['run_workflow', undefined], ['gmail_send', undefined], diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index da8f2ac259b..7b0503f3bf7 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -48,12 +48,11 @@ export async function executeTool( (context.workspaceId || context.workflowId || context.requestMode !== 'assistant' || - !['search_workspace', 'read_document', 'list_integrations'].includes(toolId)) + !['search_workspace', 'read_document'].includes(toolId)) ) { return { success: false, - error: - 'Organization Assistant can search documents and inspect personal Search integrations.', + error: 'Organization Assistant can search and read documents.', } } if (context.requestMode === 'assistant' && !ASSISTANT_TOOLS.has(toolId)) { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.test.ts deleted file mode 100644 index 000825ddc6b..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const m = vi.hoisted(() => ({ read: vi.fn(), authorizeChat: vi.fn() })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ - authorizeOrganizationChatDelegation: { execute: m.authorizeChat }, -})) -vi.mock('@/lib/knowledge/application/personal-search-integrations', () => ({ - listPersonalSearchIntegrations: { - get operation() { - return knowledgeOperations.listPersonalSearchIntegrations - }, - execute: m.read, - }, -})) - -import { listIntegrationsServerTool } from '@/lib/copilot/tools/server/knowledge/list-integrations' -import { knowledgeOperations } from '@/lib/knowledge/application/operations' - -const context = { - userId: 'person', - organizationId: 'org', - chatId: 'private-chat', - toolCallId: 'call', - requestMode: 'assistant', - copilotToolExecution: true, -} -beforeEach(() => { - vi.clearAllMocks() - m.authorizeChat.mockResolvedValue(undefined) - m.read.mockResolvedValue({ connections: [], available: [], nextCursor: null }) -}) -describe('list_integrations', () => { - it('binds the read to the authorized current person and private organization chat', async () => { - expect( - await listIntegrationsServerTool.execute({ connectorType: 'gmail', cursor: 'page' }, context) - ).toMatchObject({ success: true }) - expect(m.read).toHaveBeenCalledWith({ - principal: expect.objectContaining({ - subjectUserId: 'person', - organizationId: 'org', - resourceScope: { chatId: 'private-chat' }, - }), - input: { organizationId: 'org', connectorType: 'gmail', cursor: 'page' }, - }) - expect(m.authorizeChat).toHaveBeenCalledOnce() - }) - it.each([ - { organizationId: 'forged' }, - { userId: 'another' }, - { connectorType: 'x'.repeat(101) }, - { cursor: 'x'.repeat(1025) }, - ])('rejects forged scope and unbounded arguments', async (args) => { - expect(await listIntegrationsServerTool.execute(args, context)).toMatchObject({ - success: false, - }) - expect(m.read).not.toHaveBeenCalled() - }) - it.each([ - { ...context, workspaceId: 'workspace' }, - { ...context, requestMode: 'agent' }, - { ...context, chatId: undefined }, - { ...context, copilotToolExecution: false }, - ])('rejects untrusted or non-organization contexts', async (invalid) => { - expect(await listIntegrationsServerTool.execute({}, invalid)).toMatchObject({ success: false }) - expect(m.read).not.toHaveBeenCalled() - }) - it('checks revoked chat access before returning any account data', async () => { - m.authorizeChat.mockRejectedValue(new Error('revoked')) - expect(await listIntegrationsServerTool.execute({}, context)).toMatchObject({ success: false }) - expect(m.read).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts b/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts deleted file mode 100644 index eae98d2763a..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/list-integrations.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import { - executeCopilotOrganizationKnowledgeUseCase, - messageForCopilotKnowledgeError, - requireCopilotKnowledgeScope, -} from '@/lib/copilot/application/execute-knowledge-use-case' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' - -const logger = createLogger('ListSearchIntegrations') -const inputSchema = z - .object({ - connectorType: z.string().trim().min(1).max(100).optional(), - cursor: z.string().min(1).max(1024).optional(), - }) - .strict() - -export const listIntegrationsServerTool: BaseServerTool = { - name: 'list_integrations', - async execute(raw, context) { - try { - const scope = requireCopilotKnowledgeScope(context) - if (scope.kind !== 'organization') - throw new Error('Integration inventory requires organization Search') - const input = inputSchema.parse(raw) - const data = await executeCopilotOrganizationKnowledgeUseCase( - context, - listPersonalSearchIntegrations, - { - ...input, - organizationId: scope.organizationId, - } - ) - return { - success: true, - data, - message: - 'These are your current Search connections. Connected does not mean indexed. To offer a connection, emit the exact action or target inside a terminal tag, without a URL. Refresh this inventory after the user submits connection status. Follow nextCursor before claiming this list is complete.', - } - } catch (error) { - logger.error('Could not list personal Search integrations', { error }) - return { - success: false, - message: - error instanceof z.ZodError - ? 'Invalid integration inventory arguments' - : messageForCopilotKnowledgeError(error), - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index e8506a24464..0c499a66e9e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -44,7 +44,6 @@ import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/worksp import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { listIntegrationsServerTool } from '@/lib/copilot/tools/server/knowledge/list-integrations' import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' import { readDocumentServerTool, @@ -183,7 +182,6 @@ const baseServerToolRegistry: Record = { [getCredentialsServerTool.name]: getCredentialsServerTool, [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, - [listIntegrationsServerTool.name]: listIntegrationsServerTool, [searchWorkspaceServerTool.name]: searchWorkspaceServerTool, [readDocumentServerTool.name]: readDocumentServerTool, [enrichmentRunServerTool.name]: enrichmentRunServerTool, diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 166840b8c22..da2408eb90b 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -565,7 +565,6 @@ const TOOL_TITLES: Record = { edit_workflow: 'Editing workflow', manage_knowledge_base: 'Managing knowledge base', search_knowledge_base: 'Searching knowledge base', - list_integrations: 'Checking your integrations', search_workspace: 'Searching documents', read_document: 'Reading document', open_resource: 'Opening resource', diff --git a/apps/sim/lib/slack-search/assistant-stream.test.ts b/apps/sim/lib/slack-search/assistant-stream.test.ts index f2cdfef06e3..5a3b53ce65d 100644 --- a/apps/sim/lib/slack-search/assistant-stream.test.ts +++ b/apps/sim/lib/slack-search/assistant-stream.test.ts @@ -86,7 +86,7 @@ function setup(deliverConnections = vi.fn().mockResolvedValue(undefined)) { } } -function toolCall(toolName = 'list_integrations', toolCallId = 'tool-1'): ToolCallStreamEvent { +function toolCall(toolName = 'search_workspace', toolCallId = 'tool-1'): ToolCallStreamEvent { return { type: 'tool', payload: { @@ -101,7 +101,7 @@ function toolCall(toolName = 'list_integrations', toolCallId = 'tool-1'): ToolCa } function toolResult( - toolName = 'list_integrations', + toolName = 'search_workspace', toolCallId = 'tool-1', success = true ): ToolResultStreamEvent { @@ -386,7 +386,6 @@ describe('Slack tool progress', () => { }) it.each([ - ['list_integrations', 'Listing connected integrations…'], ['search_workspace', 'Searching documents…'], ['read_document', 'Reading documents…'], ])('shows %s as a task and completes that same task once', async (name, title) => { @@ -446,7 +445,7 @@ describe('Slack tool progress', () => { ...call, payload: { ...call.payload, arguments: { query: 'private argument' } }, }) - const failed = toolResult('list_integrations', 'tool-1', false) + const failed = toolResult('search_workspace', 'tool-1', false) await stream.onEvent({ ...failed, payload: { diff --git a/apps/sim/lib/slack-search/assistant-stream.ts b/apps/sim/lib/slack-search/assistant-stream.ts index 07767a48d2d..c6d85a9d95d 100644 --- a/apps/sim/lib/slack-search/assistant-stream.ts +++ b/apps/sim/lib/slack-search/assistant-stream.ts @@ -107,7 +107,6 @@ const FAILURE_BLOCKS: Record[] = [ ] const TOOL_PROGRESS_TITLES = new Map([ - ['list_integrations', 'Listing connected integrations…'], ['search_workspace', 'Searching documents…'], ['read_document', 'Reading documents…'], ])