Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions apps/sim/lib/copilot/application/load-search-integrations.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof listPersonalSearchIntegrations.execute>>

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)
})
})
63 changes: 63 additions & 0 deletions apps/sim/lib/copilot/application/load-search-integrations.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof listPersonalSearchIntegrations.execute>>

/** Loads the complete current person's Search inventory for one authenticated chat turn. */
export async function loadCopilotSearchIntegrations(
context: SearchIntegrationsContext
): Promise<string> {
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<string, IntegrationInventory['available'][number]>()
const cursors = new Set<string>()
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')
}
1 change: 0 additions & 1 deletion apps/sim/lib/copilot/assistant/tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 0 additions & 18 deletions apps/sim/lib/copilot/generated/tool-catalog-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -7664,7 +7647,6 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[Knowledge.id]: Knowledge,
[ListDeploymentVersions.id]: ListDeploymentVersions,
[ListIntegrationTools.id]: ListIntegrationTools,
[ListIntegrations.id]: ListIntegrations,
[ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers,
[LoadDeployment.id]: LoadDeployment,
[LoadIntegrationTool.id]: LoadIntegrationTool,
Expand Down
19 changes: 0 additions & 19 deletions apps/sim/lib/copilot/generated/tool-schemas-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3474,25 +3474,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
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',
Expand Down
Loading
Loading