From 09cab7816c3a9456898ff5da04c50ec14c464d38 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 1 Oct 2026 14:10:45 -0700 Subject: [PATCH 1/2] fix(search): correct onboarding and live citations --- .../components/get-started/get-started.tsx | 12 +- .../knowledge/search-integrations.ts | 1 + .../search-mcp-setup.integration.ts | 204 ++++++++++++++++++ .../application/search-integrations.ts | 106 ++++++++- .../sim/lib/knowledge/search/citation.test.ts | 53 ----- apps/sim/lib/knowledge/search/citation.ts | 28 --- .../lib/knowledge/search/source-url.test.ts | 26 +++ .../mothership/generated/tool-catalog-v1.ts | 1 - .../mothership/generated/tool-schemas-v1.ts | 4 - .../server/knowledge/workspace-search.test.ts | 74 +++++++ .../server/knowledge/workspace-search.ts | 21 +- packages/db/schema.ts | 18 +- 12 files changed, 429 insertions(+), 119 deletions(-) delete mode 100644 apps/sim/lib/knowledge/search/citation.test.ts create mode 100644 apps/sim/lib/knowledge/search/source-url.test.ts diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx index f9e42b544c8..42f8f800cac 100644 --- a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx @@ -71,7 +71,7 @@ function StepMark({ complete }: { complete: boolean }) { * The organization home's onboarding list under the composer. Same chrome as * the workspace home's suggested actions: a hover-revealed disclosure header * over hairline-separated rows. Each step leads to the page that completes it, - * and reads as done from the organization's real state: a connected account and an OAuth app authorized to use Search. + * and reads as done from the organization's real state: a configured integration and an OAuth app authorized to use Search. */ export function GetStarted() { const { organization, viewer, connectedAccountsAvailable } = useOrganizationContext() @@ -142,7 +142,15 @@ export function GetStarted() { 'connect-sim-search': routes.settingsSection('search-mcp'), } const completed: Record = { - 'connect-integration': Boolean(hasSearchConnection), + 'connect-integration': Boolean( + hasSearchConnection || + integrations?.some( + (integration) => + integration.approved && + integration.available !== false && + integration.configuredServiceSource + ) + ), 'connect-sim-search': hasSearchAuthorization, } const steps = STEPS.filter((step) => diff --git a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts index b0e199baef0..ba92d243653 100644 --- a/apps/sim/lib/api/contracts/knowledge/search-integrations.ts +++ b/apps/sim/lib/api/contracts/knowledge/search-integrations.ts @@ -13,6 +13,7 @@ export type SearchIntegrationApproval = z.output diff --git a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts index 41edfd940a4..2d255f95088 100644 --- a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts @@ -3,6 +3,8 @@ import { credential, credentialGroup, credentialGroupEnrollment, + knowledgeBase, + knowledgeConnector, mcpServers, member, organization, @@ -35,6 +37,10 @@ import { approveSearchIntegration, listSearchIntegrations, } from '@/lib/knowledge/application/search-integrations' +import { + GITHUB_INSTALLATION_PROVIDER_ID, + type GitHubInstallationBinding, +} from '@/lib/oauth/github-installation-types' import { defaultLiveSearchPolicy } from '@/lib/sim-search/live/policy-schema' import { SLACK_RTS_USER_SCOPES } from '@/lib/sim-search/live/scopes' @@ -247,6 +253,204 @@ describe('atomic organization live Search MCP setup', () => { } ) + async function seedServiceSource(provider: 'google_drive' | 'github' | 'gitlab') { + const knowledgeBaseId = generateId() + const connectorId = generateId() + const credentialId = generateId() + const installation = { + type: 'github_app_installation', + version: 1, + appId: '1', + appClientId: 'fixture-github-app', + installationId: '21', + accountId: '11', + accountType: 'Organization', + accountLogin: 'fixture-owner', + repositorySelection: 'selected', + } satisfies GitHubInstallationBinding + const encryptedInstallation = + provider === 'github' ? await encryptSecret(JSON.stringify(installation)) : undefined + await db.insert(knowledgeBase).values({ + id: knowledgeBaseId, + userId: ids.owner, + organizationId: ids.organization, + isSearchIndex: true, + name: 'Service source fixture', + }) + await db.insert(credential).values({ + id: credentialId, + organizationId: ids.organization, + type: 'service_account', + providerId: provider === 'github' ? GITHUB_INSTALLATION_PROVIDER_ID : 'google-drive', + ...(encryptedInstallation + ? { + encryptedServiceAccountKey: encryptedInstallation.encrypted, + providerSubjectId: installation.installationId, + providerTenantId: installation.accountId, + authorizationAppId: installation.appClientId, + } + : {}), + displayName: 'Service source fixture', + createdBy: ids.owner, + }) + await db.insert(knowledgeConnector).values({ + id: connectorId, + knowledgeBaseId, + connectorType: provider, + credentialId, + encryptedApiKey: provider === 'gitlab' ? 'synthetic-encrypted-key' : null, + sourceConfig: + provider === 'github' + ? { repository: 'fixture-owner/repository', githubRepositoryId: '101' } + : {}, + accessMode: provider === 'github' ? 'members' : 'admin', + status: 'active', + }) + await db.insert(organizationSearchIntegration).values({ + organizationId: ids.organization, + connectorType: provider, + approved: true, + }) + await db + .update(organization) + .set({ + metadata: { + liveSearchPolicies: { + [provider]: { + ...defaultLiveSearchPolicy(provider), + accessMode: 'service_account', + ...(provider === 'google_drive' ? { sourceId: connectorId } : {}), + }, + }, + }, + }) + .where(eq(organization.id, ids.organization)) + return { knowledgeBaseId, connectorId, credentialId } + } + + async function integrationStatus(provider: string) { + const data = await listSearchIntegrations.execute({ + principal: createSessionPrincipal({ userId: ids.member, sessionId: generateId() }), + input: { organizationId: ids.organization }, + }) + return listSearchIntegrationsContract.response.schema + .parse({ success: true, data }) + .data.find((entry) => entry.connectorType === provider) + } + + it.each(['google_drive', 'github', 'gitlab'] as const)( + 'reports a configured %s service source without requiring a member account', + async (provider) => { + const source = await seedServiceSource(provider) + expect((await snapshot()).groups).toEqual([]) + expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: true }) + await db + .update(knowledgeConnector) + .set({ status: 'disabled' }) + .where(eq(knowledgeConnector.id, source.connectorId)) + expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false }) + await db + .update(knowledgeConnector) + .set({ status: 'active', archivedAt: new Date() }) + .where(eq(knowledgeConnector.id, source.connectorId)) + expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false }) + await db + .update(knowledgeConnector) + .set({ archivedAt: null }) + .where(eq(knowledgeConnector.id, source.connectorId)) + await db + .update(knowledgeBase) + .set({ deletedAt: new Date() }) + .where(eq(knowledgeBase.id, source.knowledgeBaseId)) + expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false }) + await db + .update(knowledgeBase) + .set({ deletedAt: null }) + .where(eq(knowledgeBase.id, source.knowledgeBaseId)) + await db + .update(organizationSearchIntegration) + .set({ approved: false }) + .where(eq(organizationSearchIntegration.organizationId, ids.organization)) + expect(await integrationStatus(provider)).toMatchObject({ configuredServiceSource: false }) + } + ) + + it('requires the selected service source to belong to this organization and provider', async () => { + const source = await seedServiceSource('google_drive') + const otherOrganizationId = generateId() + await db.insert(organization).values({ + id: otherOrganizationId, + name: 'Other service fixture', + slug: otherOrganizationId, + }) + try { + await db + .update(knowledgeBase) + .set({ organizationId: otherOrganizationId }) + .where(eq(knowledgeBase.id, source.knowledgeBaseId)) + expect(await integrationStatus('google_drive')).toMatchObject({ + configuredServiceSource: false, + }) + await db + .update(knowledgeBase) + .set({ organizationId: ids.organization }) + .where(eq(knowledgeBase.id, source.knowledgeBaseId)) + await db + .update(knowledgeConnector) + .set({ connectorType: 'confluence' }) + .where(eq(knowledgeConnector.id, source.connectorId)) + expect(await integrationStatus('google_drive')).toMatchObject({ + configuredServiceSource: false, + }) + await db + .update(knowledgeConnector) + .set({ connectorType: 'google_drive' }) + .where(eq(knowledgeConnector.id, source.connectorId)) + await db + .update(organization) + .set({ + metadata: { + liveSearchPolicies: { + google_drive: { + ...defaultLiveSearchPolicy(), + accessMode: 'service_account', + sourceId: generateId(), + }, + }, + }, + }) + .where(eq(organization.id, ids.organization)) + expect(await integrationStatus('google_drive')).toMatchObject({ + configuredServiceSource: false, + }) + } finally { + await db.delete(organization).where(eq(organization.id, otherOrganizationId)) + } + }) + + it('does not count a GitHub member source without its active installation credential', async () => { + const source = await seedServiceSource('github') + await db + .update(credential) + .set({ revokedAt: new Date() }) + .where(eq(credential.id, source.credentialId)) + expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false }) + await db + .update(credential) + .set({ revokedAt: null }) + .where(eq(credential.id, source.credentialId)) + await db + .update(knowledgeConnector) + .set({ memberSyncStatus: 'disabled' }) + .where(eq(knowledgeConnector.id, source.connectorId)) + expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false }) + await db + .update(knowledgeConnector) + .set({ memberSyncStatus: 'idle', sourceConfig: {} }) + .where(eq(knowledgeConnector.id, source.connectorId)) + expect(await integrationStatus('github')).toMatchObject({ configuredServiceSource: false }) + }) + it('keeps disabled Zoom approvals visible and removable without permitting reapproval', async () => { const connectorType = 'zoom' await db.insert(organizationSearchIntegration).values({ diff --git a/apps/sim/lib/knowledge/application/search-integrations.ts b/apps/sim/lib/knowledge/application/search-integrations.ts index 2e5b97ca5db..697eeb7317f 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.ts @@ -1,17 +1,25 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' -import { organization, organizationSearchIntegration } from '@sim/db/schema' -import { eq, sql } from 'drizzle-orm' +import { + credential, + knowledgeBase, + knowledgeConnector, + organization, + organizationSearchIntegration, +} from '@sim/db/schema' +import { and, eq, exists, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability' import { addOrganizationAccountProvider } from '@/lib/credential-groups/service' import { SLACK_SEARCH_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' +import { resolveKnowledgeAccessAvailability } from '@/lib/knowledge/access/availability' import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { listOrganizationSearchApprovals } from '@/lib/knowledge/search/integration-policy' +import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { refuseCapability } from '@/lib/permission-groups/capabilities' import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { NativeSearchError } from '@/lib/sim-search/live/http' @@ -26,6 +34,7 @@ import { normalizeLiveSearchPolicy, } from '@/lib/sim-search/live/policy-schema' import { livePolicyFor, loadLiveSearchPolicies } from '@/lib/sim-search/live/policy-store' +import { supportsLiveSearchMode } from '@/lib/sim-search/live/provider-catalog' import { isSearchProviderEnabled } from '@/lib/sim-search/live/provider-rollout' import { loadLiveServiceSource } from '@/lib/sim-search/live/service-sources' import { @@ -33,6 +42,7 @@ import { liveSearchMcpConnector, liveSearchMemberAccountProvider, } from '@/lib/sim-search/live/source-catalog' +import { getConnectorMeta } from '@/connectors/registry' interface SearchIntegrationInput { organizationId: string @@ -54,7 +64,7 @@ export const listSearchIntegrations = defineAuthorizedKnowledgeUseCase({ const policies = await loadLiveSearchPolicies({ organizationId: context.organizationId }) const scope = { kind: 'organization', organizationId: context.organizationId } as const const zoomEnabled = await isSearchProviderEnabled('zoom', scope) - return LIVE_SEARCH_SOURCE_TYPES.map(([connectorType]) => ({ + const integrations = LIVE_SEARCH_SOURCE_TYPES.map(([connectorType]) => ({ connectorType, approved: approvals.get(connectorType) ?? false, ...(policies @@ -64,6 +74,96 @@ export const listSearchIntegrations = defineAuthorizedKnowledgeUseCase({ } : {}), })) + const servicePolicies = integrations.filter( + (integration) => + integration.approved && + integration.available !== false && + integration.policy?.accessMode === 'service_account' && + supportsLiveSearchMode(integration.connectorType, 'service_account') + ) + const availability = servicePolicies.length + ? await resolveKnowledgeAccessAvailability(context) + : null + const candidates = servicePolicies.filter( + (integration) => + availability?.sourceMirrored && + (availability.memberScoped || + (integration.connectorType !== 'github' && + !getConnectorMeta(integration.connectorType)?.requiresMemberIdentity)) + ) + const configured = candidates.length + ? await db + .select({ provider: sql`requested.provider` }) + .from( + sql`(VALUES ${sql.join( + candidates.map( + (integration) => + sql`(${integration.connectorType}::text, ${integration.policy?.sourceId ?? null}::text)` + ), + sql`, ` + )}) AS requested(provider, source_id)` + ) + .where( + exists( + db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeBase.id, knowledgeConnector.knowledgeBaseId)) + .where( + and( + eq(knowledgeBase.organizationId, context.organizationId), + eq(knowledgeBase.isSearchIndex, true), + isNull(knowledgeBase.deletedAt), + eq(knowledgeConnector.connectorType, sql`requested.provider`), + inArray(knowledgeConnector.status, ['active', 'pending', 'syncing', 'error']), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + or( + and( + sql`requested.provider = 'github'`, + eq(knowledgeConnector.accessMode, 'members'), + ne(knowledgeConnector.memberSyncStatus, 'disabled'), + sql`jsonb_typeof(${knowledgeConnector.sourceConfig}::jsonb->'githubRepositoryId') = 'string'`, + exists( + db + .select({ id: credential.id }) + .from(credential) + .where( + and( + eq(credential.id, knowledgeConnector.credentialId), + eq(credential.organizationId, context.organizationId), + eq(credential.type, 'service_account'), + eq(credential.providerId, GITHUB_INSTALLATION_PROVIDER_ID), + isNull(credential.revokedAt) + ) + ) + ) + ), + and( + sql`requested.provider <> 'github'`, + eq(knowledgeConnector.accessMode, 'admin'), + or( + and( + sql`requested.provider = 'gitlab'`, + isNotNull(knowledgeConnector.encryptedApiKey) + ), + and( + sql`requested.provider <> 'gitlab'`, + eq(knowledgeConnector.id, sql`requested.source_id`) + ) + ) + ) + ) + ) + ) + ) + ) + : [] + const configuredProviders = new Set(configured.map((row) => row.provider)) + return integrations.map((integration) => ({ + ...integration, + configuredServiceSource: configuredProviders.has(integration.connectorType), + })) }, }) diff --git a/apps/sim/lib/knowledge/search/citation.test.ts b/apps/sim/lib/knowledge/search/citation.test.ts deleted file mode 100644 index d9332b9c65b..00000000000 --- a/apps/sim/lib/knowledge/search/citation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createKnowledgeDocumentCitation } from '@/lib/knowledge/search/citation' -import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url' - -const input = { - scope: { kind: 'workspace', workspaceId: 'workspace/a' } as const, - knowledgeBaseId: 'kb/b', - documentId: 'doc/c', - baseUrl: 'https://www.sim.ai', - sourceUrl: 'https://docs.google.com/document/d/abc/edit?tab=t.0#heading', -} - -describe('knowledge citations', () => { - it.each([ - null, - '', - 'javascript:alert(1)', - 'data:text/html,secret', - 'https://user:secret@source.test/doc', - 'https://source.test/with\nnewline', - 'https://source.test\\@evil.test/doc', - '/relative/path', - 'https:source.test/doc', - '//source.test/doc', - ])('uses a scoped Sim link when the source URL is %s', (sourceUrl) => { - expect(createKnowledgeDocumentCitation({ ...input, sourceUrl }).citationUrl).toBe( - 'https://www.sim.ai/workspace/workspace%2Fa/knowledge/kb%2Fb/doc%2Fc' - ) - }) - - it('links organization documents under their own organization', () => { - expect( - createKnowledgeDocumentCitation({ - ...input, - scope: { kind: 'organization', organizationId: 'org/a' }, - sourceUrl: null, - }).citationUrl - ).toBe('https://www.sim.ai/o/org%2Fa/knowledge/kb%2Fb/doc%2Fc') - }) - - it.each(['file:///tmp/app', 'javascript:alert(1)', 'https://secret@sim.ai'])( - 'rejects unsafe application URL %s', - (baseUrl) => { - expect(() => createKnowledgeDocumentCitation({ ...input, baseUrl })).toThrow( - 'Invalid citation base URL' - ) - } - ) - - it('rejects whitespace in exact provider references', () => { - expect(isKnowledgeSourceUrl(` ${input.sourceUrl}`)).toBe(false) - }) -}) diff --git a/apps/sim/lib/knowledge/search/citation.ts b/apps/sim/lib/knowledge/search/citation.ts index 02880594948..c6eb6489991 100644 --- a/apps/sim/lib/knowledge/search/citation.ts +++ b/apps/sim/lib/knowledge/search/citation.ts @@ -1,34 +1,6 @@ import { sha256Hex } from '@sim/security/hash' -import type { ResourceScope } from '@/lib/core/resource-scope' -import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url' /** Stable opaque citation IDs keep the model from rewriting long live references. */ export function liveCitationId(documentId: string): string { return `live:${sha256Hex(documentId).slice(0, 32)}` } - -interface KnowledgeDocumentCitationInput { - scope: ResourceScope - knowledgeBaseId: string - documentId: string - sourceUrl: string | null - baseUrl: string -} - -/** Uses the original source when safe, otherwise the authorized Sim document page. */ -export function createKnowledgeDocumentCitation(input: KnowledgeDocumentCitationInput) { - if (!isKnowledgeSourceUrl(input.baseUrl)) throw new Error('Invalid citation base URL') - const ownerPath = - input.scope.kind === 'organization' - ? `/o/${encodeURIComponent(input.scope.organizationId)}` - : `/workspace/${encodeURIComponent(input.scope.workspaceId)}` - const documentPath = `${ownerPath}/knowledge/${encodeURIComponent(input.knowledgeBaseId)}/${encodeURIComponent(input.documentId)}` - const sourceUrl = input.sourceUrl?.trim() - return { - citationId: `document:${input.documentId}`, - citationUrl: - sourceUrl && isKnowledgeSourceUrl(sourceUrl) - ? sourceUrl - : new URL(documentPath, input.baseUrl).href, - } -} diff --git a/apps/sim/lib/knowledge/search/source-url.test.ts b/apps/sim/lib/knowledge/search/source-url.test.ts new file mode 100644 index 00000000000..c8560bdfa05 --- /dev/null +++ b/apps/sim/lib/knowledge/search/source-url.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { isKnowledgeSourceUrl } from '@/lib/knowledge/search/source-url' + +describe('knowledge source URLs', () => { + it.each([ + '', + 'file:///tmp/app', + 'javascript:alert(1)', + 'data:text/html,secret', + 'https://user:secret@source.test/doc', + 'https://source.test/with\nnewline', + 'https://source.test\\@evil.test/doc', + '/relative/path', + 'https:source.test/doc', + '//source.test/doc', + ' https://source.test/doc', + ])('rejects unsafe provider link %s', (sourceUrl) => { + expect(isKnowledgeSourceUrl(sourceUrl)).toBe(false) + }) + + it('accepts a provider link with its query and fragment intact', () => { + expect( + isKnowledgeSourceUrl('https://docs.google.com/document/d/abc/edit?tab=t.0#heading') + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts index eb42cd17d8d..bf94e2d5695 100644 --- a/apps/sim/lib/mothership/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts @@ -7893,7 +7893,6 @@ export const SearchSources: ToolCatalogEntry = { type: 'string', maxLength: 200, }, - mine: { description: 'Only used for action: list. Omit for other actions.', type: 'boolean' }, connectorId: { description: 'Required for action: get. Only used for action: get. Omit for other actions.', type: 'string', diff --git a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts index ac3bacd5b5b..07020b2a423 100644 --- a/apps/sim/lib/mothership/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts @@ -8064,10 +8064,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', maxLength: 200, }, - mine: { - description: 'Only used for action: list. Omit for other actions.', - type: 'boolean', - }, connectorId: { description: 'Required for action: get. Only used for action: get. Omit for other actions.', diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts index 1b78b765008..a926b851cdc 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts @@ -27,6 +27,8 @@ vi.mock('@/lib/sim-search/live/application', () => ({ })) import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { collectRetrievalCitationEvidence } from '@/lib/mothership/chat/citation-evidence' +import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations' import { readDocumentServerTool, searchWorkspaceServerTool, @@ -81,6 +83,78 @@ describe('Assistant retrieval tools', () => { next: null, }) }) + for (const tool of [searchWorkspaceServerTool, readDocumentServerTool]) { + describe(`${tool.name} live citations`, () => { + it.each([ + { + name: 'the source link before its container', + sourceUrl: 'https://source.test/document', + sourceContainerUrl: 'https://source.test/container', + citationUrl: 'https://source.test/document', + }, + { + name: 'the container when the source has no link', + sourceUrl: null, + sourceContainerUrl: 'https://source.test/container', + citationUrl: 'https://source.test/container', + }, + { + name: 'no citation link when neither destination is available', + sourceUrl: null, + sourceContainerUrl: undefined, + citationUrl: null, + }, + ])('preserves $name through saved citation evidence', async (links) => { + const document = { + knowledgeBaseId: '', + documentId: 'live:opaque-reference', + documentName: 'Live document', + sourceUrl: links.sourceUrl, + sourceContainerUrl: links.sourceContainerUrl, + connectorType: 'slack', + content: 'Retrieved evidence', + } + const searching = tool.name === 'search_workspace' + if (searching) { + mocks.search.mockResolvedValueOnce({ + retrieval: { status: 'complete', timedOutLegs: [] }, + results: [document], + }) + } else { + mocks.read.mockResolvedValueOnce({ + ...document, + chunks: [{ chunkIndex: 0, content: document.content }], + hasMore: false, + next: null, + }) + } + const result = await tool.execute( + searching ? { query: 'evidence' } : { documentId: document.documentId }, + { ...context, assistantSearch: undefined } + ) + const expected = { documentId: document.documentId, citationUrl: links.citationUrl } + expect(result).toMatchObject({ + success: true, + data: searching ? { results: [expected] } : expected, + }) + const evidence = collectRetrievalCitationEvidence([ + { + toolCall: { + name: tool.name, + status: 'success', + result: { + success: true, + output: compactRetrievalCitations(tool.name, result), + }, + }, + }, + ]) + expect([...evidence.values()].map((source) => source.url)).toEqual( + links.citationUrl ? [links.citationUrl] : [] + ) + }) + }) + } it.each([{ startDate: '2026-09-01T00:00:00Z' }, { sortBy: 'newest' }, { sortBy: 'oldest' }])( 'returns actionable validation for empty Notion native queries with %j', async (bound) => { diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts index ce9957cfec1..098f5e0191c 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts @@ -5,10 +5,9 @@ import { searchWorkspaceInputSchema, } from '@/lib/api/contracts/mothership-assistant-tools' import { getValidationErrorMessage } from '@/lib/api/server/validation' -import { getBaseUrl } from '@/lib/core/utils/urls' import { EmbeddingConfigurationError } from '@/lib/embeddings/configuration-error' import { SearchDeadlineError } from '@/lib/knowledge/search/budget' -import { createKnowledgeDocumentCitation, liveCitationId } from '@/lib/knowledge/search/citation' +import { liveCitationId } from '@/lib/knowledge/search/citation' import { withSearchDiagnostics } from '@/lib/knowledge/search/diagnostics' import { intersectWorkspaceSearchFilters } from '@/lib/knowledge/search/filters' import { @@ -25,7 +24,7 @@ import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-sec const logger = createLogger('WorkspaceSearchTool') const CITATION_INSTRUCTION = - 'Cite the evidence you use as {"id":""}. Use only IDs returned by these tools.' + + 'Cite the evidence you use as {"id":""}. Use only IDs returned by these tools with a non-null citationUrl.' + ' When referring to a Slack conversation, link the returned sourceContainerName to its sourceContainerUrl when available.' export const searchWorkspaceServerTool: BaseServerTool = { @@ -93,14 +92,8 @@ export const searchWorkspaceServerTool: BaseServerTool = { results: data.results.map((item) => ({ ...item, siteName: connectorDisplayName(item.connectorType ?? ''), - ...createKnowledgeDocumentCitation({ - scope, - knowledgeBaseId: '', - documentId: item.documentId, - sourceUrl: item.sourceUrl, - baseUrl: getBaseUrl(), - }), citationId: liveCitationId(item.documentId), + citationUrl: item.sourceUrl ?? item.sourceContainerUrl ?? null, })), }, } @@ -166,14 +159,8 @@ export const readDocumentServerTool: BaseServerTool = { message: CITATION_INSTRUCTION, data: { ...data, - ...createKnowledgeDocumentCitation({ - scope, - knowledgeBaseId: '', - documentId: data.documentId, - sourceUrl: data.sourceUrl, - baseUrl: getBaseUrl(), - }), citationId: liveCitationId(data.documentId), + citationUrl: data.sourceUrl ?? data.sourceContainerUrl ?? null, }, } } catch (error) { diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 0a47dffac6e..523c8f7e970 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3820,23 +3820,19 @@ export const embeddingSearch = pgTable( knowledgeBaseId: text('knowledge_base_id').notNull(), documentId: text('document_id').notNull(), enabled: boolean('enabled').notNull(), - /** - * The connector whose documents this chunk belongs to, copied from the document so a vector - * index can cover one source. A member reads a source whole or barely at all, so searching - * each readable source in its own index finds their nearest chunks; one index over every - * source spends its scan budget on chunks the graph reached but the member cannot read. - * NULL for uploads. - */ - // contract-pending(after the indexed-search retirement release and source/ACL projection writers have drained): drop connector_id — regular KB retrieval checks the parent document. + /** contract-pending(after #8528 is fully deployed and source/ACL projection writers have drained): drop connector_id — regular KB retrieval checks the parent document. */ connectorId: text('connector_id'), - /** The document's ACL, mirrored by trigger, so a walk can test readability on the row it visits. */ - // contract-pending(after the indexed-search retirement release and source/ACL projection writers have drained): drop acl — regular KB retrieval retains document-level access checks. + /** contract-pending(after #8528 is fully deployed and source/ACL projection writers have drained): drop acl — regular KB retrieval retains document-level access checks. */ acl: text('acl').array(), - // contract-pending(after vector writers stop computing binary projections and embedding_search_width_check is replaced): drop binary and all binary_* columns — their ANN indexes were dropped in 0372 and no reader uses them. + /** contract-pending(after vector writers stop computing binary projections and embedding_search_width_check is replaced): drop binary and all binary_* columns — their ANN indexes were dropped in 0372 and no reader uses them. */ binary: bit('binary', { dimensions: 1536 }), + /** @deprecated Remove with the binary projection contract above. */ binary384: bit('binary_384', { dimensions: 384 }), + /** @deprecated Remove with the binary projection contract above. */ binary768: bit('binary_768', { dimensions: 768 }), + /** @deprecated Remove with the binary projection contract above. */ binary1024: bit('binary_1024', { dimensions: 1024 }), + /** @deprecated Remove with the binary projection contract above. */ binary3072: bit('binary_3072', { dimensions: 3072 }), vector: halfvec('vector', { dimensions: 1536 }), vector384: halfvec('vector_384', { dimensions: 384 }), From 8d7f3175c5c15ff4a8ebfb337c32b1405921ab3c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 1 Oct 2026 14:37:43 -0700 Subject: [PATCH 2/2] fix(search): clear onboarding readiness after credential disconnect --- .../search-mcp-setup.integration.ts | 14 ++++++++++++++ .../knowledge/application/search-integrations.ts | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts index 2d255f95088..fc9bc7f2756 100644 --- a/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-mcp-setup.integration.ts @@ -32,6 +32,7 @@ import { import { getCredentialGroup } from '@/lib/credential-groups/service' import { SLACK_MANAGED_USER_SCOPES } from '@/lib/credential-groups/slack-managed-user-scopes' import { createOrganizationAccountsGroup } from '@/lib/credential-groups/workspace-accounts' +import { deleteConnectionCredential } from '@/lib/credentials/deletion' import { acquireAdvisoryXactLock, tryAcquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import { approveSearchIntegration, @@ -375,6 +376,19 @@ describe('atomic organization live Search MCP setup', () => { } ) + it('stops reporting a service source as configured after its credential is deleted', async () => { + const source = await seedServiceSource('google_drive') + expect(await integrationStatus('google_drive')).toMatchObject({ configuredServiceSource: true }) + await deleteConnectionCredential({ + credentialId: source.credentialId, + organizationId: ids.organization, + reason: 'user_delete', + }) + expect(await integrationStatus('google_drive')).toMatchObject({ + configuredServiceSource: false, + }) + }) + it('requires the selected service source to belong to this organization and provider', async () => { const source = await seedServiceSource('google_drive') const otherOrganizationId = generateId() diff --git a/apps/sim/lib/knowledge/application/search-integrations.ts b/apps/sim/lib/knowledge/application/search-integrations.ts index 697eeb7317f..9ed99a094bd 100644 --- a/apps/sim/lib/knowledge/application/search-integrations.ts +++ b/apps/sim/lib/knowledge/application/search-integrations.ts @@ -149,7 +149,8 @@ export const listSearchIntegrations = defineAuthorizedKnowledgeUseCase({ ), and( sql`requested.provider <> 'gitlab'`, - eq(knowledgeConnector.id, sql`requested.source_id`) + eq(knowledgeConnector.id, sql`requested.source_id`), + isNotNull(knowledgeConnector.credentialId) ) ) )