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
36 changes: 36 additions & 0 deletions apps/sim/lib/copilot/request/tools/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,42 @@ function buildPendingToolCall(): ToolCallState {
}
}

describe('tool result size diagnostics', () => {
beforeEach(() => {
vi.clearAllMocks()
completeAsyncToolCall.mockResolvedValue(null)
markAsyncToolRunning.mockResolvedValue(null)
upsertAsyncToolCall.mockResolvedValue(null)
})

it.each(['é🔎', { content: 'é🔎' }])(
'records UTF-8 bytes after result projection for %j',
async (output) => {
executeTool.mockResolvedValueOnce({ success: true, output })
const toolCall = buildPendingToolCall()
const context = buildStreamingContext(toolCall)
const endSpan = vi.spyOn(context.trace, 'endSpan')

const completion = await executeToolAndReport(toolCall.id, context, {
userId: 'user-1',
workflowId: 'workflow-1',
resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(),
})

expect(completion.status).toBe(MothershipStreamV1ToolOutcome.success)
const serialized =
typeof completion.data === 'string' ? completion.data : JSON.stringify(completion.data)
expect(endSpan).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'tool.execute',
attributes: expect.objectContaining({ outputBytes: Buffer.byteLength(serialized) }),
}),
'ok'
)
}
)
})

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)
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/copilot/request/tools/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,11 @@ function summarizeToolResultForSpan(result: {
const output = (result as { output: unknown }).output
if (typeof output === 'string') {
summary.outputKind = 'string'
summary.outputBytes = output.length
summary.outputBytes = Buffer.byteLength(output)
} else if (output && typeof output === 'object') {
summary.outputKind = Array.isArray(output) ? 'array' : 'object'
try {
summary.outputBytes = JSON.stringify(output).length
summary.outputBytes = Buffer.byteLength(JSON.stringify(output))
} catch {
summary.outputBytes = 0
}
Expand All @@ -143,7 +143,7 @@ function summarizeToolResultForSpan(result: {
}
} else if (output !== undefined && output !== null) {
summary.outputKind = typeof output
summary.outputBytes = String(output).length
summary.outputBytes = Buffer.byteLength(String(output))
}
return summary
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ search: vi.fn(), read: vi.fn(), authorizeChat: vi.fn() }))
const mocks = vi.hoisted(() => ({
search: vi.fn(),
read: vi.fn(),
authorizeChat: vi.fn(),
info: vi.fn(),
}))
vi.mock('@sim/logger', () => ({
createLogger: () => ({ info: mocks.info, error: vi.fn(), warn: vi.fn() }),
}))
vi.mock('@/lib/copilot/chat/organization-chats', () => ({
authorizeOrganizationChatDelegation: { execute: mocks.authorizeChat },
}))
Expand Down Expand Up @@ -179,6 +187,48 @@ describe('Assistant retrieval tools', () => {
})
)
})
it.each([0, 20, 50])(
'measures UTF-8 bytes for %i passages without logging their content',
async (count) => {
const content = 'Confidential passage é🔎'.repeat(100)
mocks.search.mockResolvedValueOnce({
knowledgeBases: [{ id: 'index', name: 'Enterprise Search' }],
results: Array.from({ length: count }, (_, index) => ({
knowledgeBaseId: 'index',
documentId: `doc-${index % 4}`,
documentName: 'Private title',
sourceUrl: null,
sourceModifiedAt: null,
metadata: {},
content,
chunkIndex: index,
similarity: 1,
})),
})

const output = await searchWorkspaceServerTool.execute(
{ query: 'Private query', ...(count === 50 ? { topK: 50 } : {}) },
context
)

expect(output.success).toBe(true)
expect(mocks.info).toHaveBeenCalledWith(
'Knowledge search completed',
expect.objectContaining({
toolCallId: 'call',
toolResultBytes: Buffer.byteLength(JSON.stringify(output)),
passageBytes: count * Buffer.byteLength(content),
maxPassageBytes: count ? Buffer.byteLength(content) : 0,
uniqueDocumentCount: Math.min(count, 4),
})
)
const logged = JSON.stringify(mocks.info.mock.calls)
expect(logged).not.toContain('Confidential passage')
expect(logged).not.toContain('Private title')
expect(logged).not.toContain('Private query')
}
)

it('returns stable citation IDs with internal links for uploaded documents', async () => {
const result = await searchWorkspaceServerTool.execute({ query: 'orion' }, context)
expect(result).toMatchObject({
Expand Down
164 changes: 96 additions & 68 deletions apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
} from '@/lib/knowledge/application/workspace-search'
import { sourceAuthor } from '@/lib/knowledge/search/author'
import { createKnowledgeDocumentCitation } from '@/lib/knowledge/search/citation'
import {
annotateSearchDiagnostics,
measureSearchStage,
recordSearchStageDuration,
withSearchDiagnostics,
} from '@/lib/knowledge/search/diagnostics'
import { intersectWorkspaceSearchFilters } from '@/lib/knowledge/search/filters'
import { connectorDisplayName } from '@/lib/sim-search/connectors'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
Expand All @@ -37,77 +43,99 @@ const CITATION_INSTRUCTION =
export const searchWorkspaceServerTool: BaseServerTool = {
name: 'search_workspace',
async execute(raw, context?: ServerToolContext) {
try {
const scope = requireCopilotKnowledgeScope(context)
const { query, topK, ...requestedFilters } = searchInputSchema.parse(raw)
const registry = context?.resolvedSecretTraceRegistry
if (!registry) throw new Error('Knowledge result provenance is unavailable')
const projected = projectResolvedSecretModelContent(query, registry)
if (!projected.safe || typeof projected.value !== 'string') {
return {
success: false,
message: 'Search query contains protected content. Rephrase the query.',
}
}
const input = {
query: projected.value,
topK,
filters: intersectWorkspaceSearchFilters(requestedFilters, context?.assistantSearch),
return withSearchDiagnostics(
{
surface: context?.searchSurface ?? 'copilot',
resultSecretRegistry: registry,
signal: context?.abortSignal,
} as const
const result =
scope.kind === 'organization'
? await executeCopilotOrganizationKnowledgeUseCase(context, searchOrganizationKnowledge, {
...input,
organizationId: scope.organizationId,
})
: await executeCopilotKnowledgeUseCase(context, searchWorkspaceKnowledge, {
...input,
workspaceId: scope.workspaceId,
toolCallId: context?.toolCallId,
executionId: context?.executionId,
},
async () => {
try {
const inputStarted = performance.now()
const scope = requireCopilotKnowledgeScope(context)
const { query, topK, ...requestedFilters } = searchInputSchema.parse(raw)
const registry = context?.resolvedSecretTraceRegistry
if (!registry) throw new Error('Knowledge result provenance is unavailable')
const projected = projectResolvedSecretModelContent(query, registry)
if (!projected.safe || typeof projected.value !== 'string') {
return {
success: false,
message: 'Search query contains protected content. Rephrase the query.',
}
}
const input = {
query: projected.value,
topK,
filters: intersectWorkspaceSearchFilters(requestedFilters, context?.assistantSearch),
surface: context?.searchSurface ?? 'copilot',
resultSecretRegistry: registry,
signal: context?.abortSignal,
} as const
recordSearchStageDuration('tool_input', performance.now() - inputStarted)
const result = await measureSearchStage('tool_application', () =>
scope.kind === 'organization'
? executeCopilotOrganizationKnowledgeUseCase(context, searchOrganizationKnowledge, {
...input,
organizationId: scope.organizationId,
})
: executeCopilotKnowledgeUseCase(context, searchWorkspaceKnowledge, {
...input,
workspaceId: scope.workspaceId,
})
)
return await measureSearchStage('tool_presentation', () => {
const names = new Map(result.knowledgeBases.map((base) => [base.id, base.name]))
const output = {
success: true,
message: `Found ${result.results.length} passages. ${CITATION_INSTRUCTION}`,
data: {
query,
results: result.results.map((item) => ({
documentId: item.documentId,
knowledgeBaseId: item.knowledgeBaseId,
knowledgeBaseName: names.get(item.knowledgeBaseId) ?? '',
siteName: item.connectorType
? connectorDisplayName(item.connectorType)
: names.get(item.knowledgeBaseId),
documentName: item.documentName,
sourceUrl: item.sourceUrl,
connectorType: item.connectorType,
sourceModifiedAt: item.sourceModifiedAt?.toISOString() ?? null,
author: sourceAuthor(item.metadata),
content: item.content,
chunkIndex: item.chunkIndex,
similarity: item.similarity,
...createKnowledgeDocumentCitation({
scope,
knowledgeBaseId: item.knowledgeBaseId,
documentId: item.documentId,
sourceUrl: item.sourceUrl,
baseUrl: getBaseUrl(),
}),
})),
},
}
const passageBytes = output.data.results.map((item) => Buffer.byteLength(item.content))
annotateSearchDiagnostics({
toolResultBytes: Buffer.byteLength(JSON.stringify(output)),
passageBytes: passageBytes.reduce((total, bytes) => total + bytes, 0),
maxPassageBytes: Math.max(0, ...passageBytes),
uniqueDocumentCount: new Set(output.data.results.map((item) => item.documentId)).size,
})
const names = new Map(result.knowledgeBases.map((base) => [base.id, base.name]))
return {
success: true,
message: `Found ${result.results.length} passages. ${CITATION_INSTRUCTION}`,
data: {
query,
results: result.results.map((item) => ({
documentId: item.documentId,
knowledgeBaseId: item.knowledgeBaseId,
knowledgeBaseName: names.get(item.knowledgeBaseId) ?? '',
siteName: item.connectorType
? connectorDisplayName(item.connectorType)
: names.get(item.knowledgeBaseId),
documentName: item.documentName,
sourceUrl: item.sourceUrl,
connectorType: item.connectorType,
sourceModifiedAt: item.sourceModifiedAt?.toISOString() ?? null,
author: sourceAuthor(item.metadata),
content: item.content,
chunkIndex: item.chunkIndex,
similarity: item.similarity,
...createKnowledgeDocumentCitation({
scope,
knowledgeBaseId: item.knowledgeBaseId,
documentId: item.documentId,
sourceUrl: item.sourceUrl,
baseUrl: getBaseUrl(),
}),
})),
},
}
} catch (error) {
logger.error('Workspace search failed', { error })
return {
success: false,
message:
error instanceof z.ZodError
? 'Invalid search arguments'
: messageForCopilotKnowledgeError(error),
return output
})
} catch (error) {
logger.error('Workspace search failed', { error })
return {
success: false,
message:
error instanceof z.ZodError
? 'Invalid search arguments'
: messageForCopilotKnowledgeError(error),
}
}
}
}
)
},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ import {
seedKnowledgeMemberFixture,
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
import { confluencePageAcl } from '@/lib/knowledge/access/confluence-permissions'
import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate'
import {
knowledgeAccessCondition,
knowledgeMetadataCandidateAccessCondition,
} from '@/lib/knowledge/access/predicate'
import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
Expand Down Expand Up @@ -299,9 +302,16 @@ describe('indexed source content through real application access', () => {
return result.results.map((row) => row.documentId)
}

it.each(['workspace', 'admin', 'members'] as const)(
'allows a remaining workspace ACL only in workspace mode, not during a %s transition',
async (accessMode) => {
it.each([
['workspace', true],
['workspace', false],
['admin', true],
['admin', false],
['members', true],
['members', false],
] as const)(
'requires settled workspace mode for a remaining workspace ACL (%s, rewrite pending=%s)',
async (accessMode, accessRewritePending) => {
const [savedConnector] = await db
.select({
accessMode: knowledgeConnector.accessMode,
Expand All @@ -324,18 +334,25 @@ describe('indexed source content through real application access', () => {
.where(eq(document.id, documentId))
await db
.update(knowledgeConnector)
.set({ accessMode, accessRewritePending: true })
.set({ accessMode, accessRewritePending })
.where(eq(knowledgeConnector.id, connectorId))
const visible = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.id, documentId),
knowledgeAccessCondition({ kind: 'workspace', tokens: ['pub', 'ws'] })
for (const accessCondition of [
knowledgeMetadataCandidateAccessCondition,
knowledgeAccessCondition,
]) {
const visible = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.id, documentId),
accessCondition({ kind: 'workspace', tokens: ['pub', 'ws'] })
)
)
expect(visible.map((row) => row.id)).toEqual(
accessMode === 'workspace' && !accessRewritePending ? [documentId] : []
)
expect(visible.map((row) => row.id)).toEqual(accessMode === 'workspace' ? [documentId] : [])
}
} finally {
await db
.update(knowledgeConnector)
Expand Down
Loading
Loading