diff --git a/packages/backend/server/src/__tests__/copilot/tool-call-loop.spec.ts b/packages/backend/server/src/__tests__/copilot/tool-call-loop.spec.ts index 846d7d96eb5bb..ee9c5f81c2b24 100644 --- a/packages/backend/server/src/__tests__/copilot/tool-call-loop.spec.ts +++ b/packages/backend/server/src/__tests__/copilot/tool-call-loop.spec.ts @@ -1,12 +1,35 @@ import test from 'ava'; import { z } from 'zod'; +import type { DocReader } from '../../core/doc'; +import type { AccessController } from '../../core/permission'; +import type { Models } from '../../models'; import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native'; import { ToolCallAccumulator, ToolCallLoop, ToolSchemaExtractor, } from '../../plugins/copilot/providers/loop'; +import { + buildBlobContentGetter, + createBlobReadTool, +} from '../../plugins/copilot/tools/blob-read'; +import { + buildDocKeywordSearchGetter, + createDocKeywordSearchTool, +} from '../../plugins/copilot/tools/doc-keyword-search'; +import { + buildDocContentGetter, + createDocReadTool, +} from '../../plugins/copilot/tools/doc-read'; +import { + buildDocSearchGetter, + createDocSemanticSearchTool, +} from '../../plugins/copilot/tools/doc-semantic-search'; +import { + DOCUMENT_SYNC_PENDING_MESSAGE, + LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE, +} from '../../plugins/copilot/tools/doc-sync'; test('ToolCallAccumulator should merge deltas and complete tool call', t => { const accumulator = new ToolCallAccumulator(); @@ -286,3 +309,210 @@ test('ToolCallLoop should surface invalid JSON as tool error without executing', is_error: true, }); }); + +test('doc_read should return specific sync errors for unavailable docs', async t => { + const cases = [ + { + name: 'local workspace without cloud sync', + workspace: null, + authors: null, + markdown: null, + expected: { + type: 'error', + name: 'Workspace Sync Required', + message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE, + }, + docReaderCalled: false, + }, + { + name: 'cloud workspace document not synced to server yet', + workspace: { id: 'ws-1' }, + authors: null, + markdown: null, + expected: { + type: 'error', + name: 'Document Sync Pending', + message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'), + }, + docReaderCalled: false, + }, + { + name: 'cloud workspace document markdown not ready yet', + workspace: { id: 'ws-1' }, + authors: { + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdByUser: null, + updatedByUser: null, + }, + markdown: null, + expected: { + type: 'error', + name: 'Document Sync Pending', + message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'), + }, + docReaderCalled: true, + }, + ] as const; + + const ac = { + user: () => ({ + workspace: () => ({ doc: () => ({ can: async () => true }) }), + }), + } as unknown as AccessController; + + for (const testCase of cases) { + let docReaderCalled = false; + const docReader = { + getDocMarkdown: async () => { + docReaderCalled = true; + return testCase.markdown; + }, + } as unknown as DocReader; + + const models = { + workspace: { + get: async () => testCase.workspace, + }, + doc: { + getAuthors: async () => testCase.authors, + }, + } as unknown as Models; + + const getDoc = buildDocContentGetter(ac, docReader, models); + const tool = createDocReadTool( + getDoc.bind(null, { + user: 'user-1', + workspace: 'workspace-1', + }) + ); + + const result = await tool.execute?.({ doc_id: 'doc-1' }, {}); + + t.is(docReaderCalled, testCase.docReaderCalled, testCase.name); + t.deepEqual(result, testCase.expected, testCase.name); + } +}); + +test('document search tools should return sync error for local workspace', async t => { + const ac = { + user: () => ({ + workspace: () => ({ + can: async () => true, + docs: async () => [], + }), + }), + } as unknown as AccessController; + + const models = { + workspace: { + get: async () => null, + }, + } as unknown as Models; + + let keywordSearchCalled = false; + const indexerService = { + searchDocsByKeyword: async () => { + keywordSearchCalled = true; + return []; + }, + } as unknown as Parameters[1]; + + let semanticSearchCalled = false; + const contextService = { + matchWorkspaceAll: async () => { + semanticSearchCalled = true; + return []; + }, + } as unknown as Parameters[1]; + + const keywordTool = createDocKeywordSearchTool( + buildDocKeywordSearchGetter(ac, indexerService, models).bind(null, { + user: 'user-1', + workspace: 'workspace-1', + }) + ); + + const semanticTool = createDocSemanticSearchTool( + buildDocSearchGetter(ac, contextService, null, models).bind(null, { + user: 'user-1', + workspace: 'workspace-1', + }) + ); + + const keywordResult = await keywordTool.execute?.({ query: 'hello' }, {}); + const semanticResult = await semanticTool.execute?.({ query: 'hello' }, {}); + + t.false(keywordSearchCalled); + t.false(semanticSearchCalled); + t.deepEqual(keywordResult, { + type: 'error', + name: 'Workspace Sync Required', + message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE, + }); + t.deepEqual(semanticResult, { + type: 'error', + name: 'Workspace Sync Required', + message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE, + }); +}); + +test('doc_semantic_search should return empty array when nothing matches', async t => { + const ac = { + user: () => ({ + workspace: () => ({ + can: async () => true, + docs: async () => [], + }), + }), + } as unknown as AccessController; + + const models = { + workspace: { + get: async () => ({ id: 'workspace-1' }), + }, + } as unknown as Models; + + const contextService = { + matchWorkspaceAll: async () => [], + } as unknown as Parameters[1]; + + const semanticTool = createDocSemanticSearchTool( + buildDocSearchGetter(ac, contextService, null, models).bind(null, { + user: 'user-1', + workspace: 'workspace-1', + }) + ); + + const result = await semanticTool.execute?.({ query: 'hello' }, {}); + + t.deepEqual(result, []); +}); + +test('blob_read should return explicit error when attachment context is missing', async t => { + const ac = { + user: () => ({ + workspace: () => ({ + allowLocal: () => ({ + can: async () => true, + }), + }), + }), + } as unknown as AccessController; + + const blobTool = createBlobReadTool( + buildBlobContentGetter(ac, null).bind(null, { + user: 'user-1', + workspace: 'workspace-1', + }) + ); + + const result = await blobTool.execute?.({ blob_id: 'blob-1' }, {}); + + t.deepEqual(result, { + type: 'error', + name: 'Blob Read Failed', + message: + 'Missing workspace, user, blob id, or copilot context for blob_read.', + }); +}); diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index 5ffab6818bd6d..b4f08d630b88f 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -470,7 +470,8 @@ export abstract class CopilotProvider { }); const searchDocs = buildDocKeywordSearchGetter( ac, - indexerService + indexerService, + models ); tools.doc_keyword_search = createDocKeywordSearchTool( searchDocs.bind(null, options) diff --git a/packages/backend/server/src/plugins/copilot/tools/blob-read.ts b/packages/backend/server/src/plugins/copilot/tools/blob-read.ts index 893e440954bdc..06e30e7e77e45 100644 --- a/packages/backend/server/src/plugins/copilot/tools/blob-read.ts +++ b/packages/backend/server/src/plugins/copilot/tools/blob-read.ts @@ -18,7 +18,10 @@ export const buildBlobContentGetter = ( chunk?: number ) => { if (!options?.user || !options?.workspace || !blobId || !context) { - return; + return toolError( + 'Blob Read Failed', + 'Missing workspace, user, blob id, or copilot context for blob_read.' + ); } const canAccess = await ac .user(options.user) @@ -29,7 +32,10 @@ export const buildBlobContentGetter = ( logger.warn( `User ${options.user} does not have access workspace ${options.workspace}` ); - return; + return toolError( + 'Blob Read Failed', + 'You do not have permission to access this workspace attachment.' + ); } const contextFile = context.files.find( @@ -42,7 +48,12 @@ export const buildBlobContentGetter = ( context.getBlobContent(canonicalBlobId, chunk), ]); const content = file?.trim() || blob?.trim(); - if (!content) return; + if (!content) { + return toolError( + 'Blob Read Failed', + `Attachment ${canonicalBlobId} is not available for reading in the current copilot context.` + ); + } const info = contextFile ? { fileName: contextFile.name, fileType: contextFile.mimeType } : {}; @@ -53,10 +64,7 @@ export const buildBlobContentGetter = ( }; export const createBlobReadTool = ( - getBlobContent: ( - targetId?: string, - chunk?: number - ) => Promise + getBlobContent: (targetId?: string, chunk?: number) => Promise ) => { return defineTool({ description: @@ -73,13 +81,10 @@ export const createBlobReadTool = ( execute: async ({ blob_id, chunk }) => { try { const blob = await getBlobContent(blob_id, chunk); - if (!blob) { - return; - } return { ...blob }; } catch (err: any) { logger.error(`Failed to read the blob ${blob_id} in context`, err); - return toolError('Blob Read Failed', err.message); + return toolError('Blob Read Failed', err.message ?? String(err)); } }, }); diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-keyword-search.ts b/packages/backend/server/src/plugins/copilot/tools/doc-keyword-search.ts index 606d5b60fed99..aad296b21deb4 100644 --- a/packages/backend/server/src/plugins/copilot/tools/doc-keyword-search.ts +++ b/packages/backend/server/src/plugins/copilot/tools/doc-keyword-search.ts @@ -1,27 +1,43 @@ import { z } from 'zod'; import type { AccessController } from '../../../core/permission'; +import type { Models } from '../../../models'; import type { IndexerService, SearchDoc } from '../../indexer'; +import { workspaceSyncRequiredError } from './doc-sync'; import { toolError } from './error'; import { defineTool } from './tool'; import type { CopilotChatOptions } from './types'; export const buildDocKeywordSearchGetter = ( ac: AccessController, - indexerService: IndexerService + indexerService: IndexerService, + models: Models ) => { const searchDocs = async (options: CopilotChatOptions, query?: string) => { - if (!options || !query?.trim() || !options.user || !options.workspace) { - return undefined; + const queryTrimmed = query?.trim(); + if (!options || !queryTrimmed || !options.user || !options.workspace) { + return toolError( + 'Doc Keyword Search Failed', + 'Missing workspace, user, or query for doc_keyword_search.' + ); + } + const workspace = await models.workspace.get(options.workspace); + if (!workspace) { + return workspaceSyncRequiredError(); } const canAccess = await ac .user(options.user) .workspace(options.workspace) .can('Workspace.Read'); - if (!canAccess) return undefined; + if (!canAccess) { + return toolError( + 'Doc Keyword Search Failed', + 'You do not have permission to access this workspace.' + ); + } const docs = await indexerService.searchDocsByKeyword( options.workspace, - query + queryTrimmed ); // filter current user readable docs @@ -29,13 +45,15 @@ export const buildDocKeywordSearchGetter = ( .user(options.user) .workspace(options.workspace) .docs(docs, 'Doc.Read'); - return readableDocs; + return readableDocs ?? []; }; return searchDocs; }; export const createDocKeywordSearchTool = ( - searchDocs: (query: string) => Promise + searchDocs: ( + query: string + ) => Promise> ) => { return defineTool({ description: @@ -50,8 +68,8 @@ export const createDocKeywordSearchTool = ( execute: async ({ query }) => { try { const docs = await searchDocs(query); - if (!docs) { - return; + if (!Array.isArray(docs)) { + return docs; } return docs.map(doc => ({ docId: doc.docId, diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-read.ts b/packages/backend/server/src/plugins/copilot/tools/doc-read.ts index 6ebfa165b52f9..2cba8f1064f5b 100644 --- a/packages/backend/server/src/plugins/copilot/tools/doc-read.ts +++ b/packages/backend/server/src/plugins/copilot/tools/doc-read.ts @@ -3,13 +3,20 @@ import { z } from 'zod'; import { DocReader } from '../../../core/doc'; import { AccessController } from '../../../core/permission'; -import { Models, publicUserSelect } from '../../../models'; -import { toolError } from './error'; +import { Models } from '../../../models'; +import { + documentSyncPendingError, + workspaceSyncRequiredError, +} from './doc-sync'; +import { type ToolError, toolError } from './error'; import { defineTool } from './tool'; import type { CopilotChatOptions } from './types'; const logger = new Logger('DocReadTool'); +const isToolError = (result: ToolError | object): result is ToolError => + 'type' in result && result.type === 'error'; + export const buildDocContentGetter = ( ac: AccessController, docReader: DocReader, @@ -17,8 +24,17 @@ export const buildDocContentGetter = ( ) => { const getDoc = async (options: CopilotChatOptions, docId?: string) => { if (!options?.user || !options?.workspace || !docId) { - return; + return toolError( + 'Doc Read Failed', + 'Missing workspace, user, or document id for doc_read.' + ); + } + + const workspace = await models.workspace.get(options.workspace); + if (!workspace) { + return workspaceSyncRequiredError(); } + const canAccess = await ac .user(options.user) .workspace(options.workspace) @@ -28,23 +44,15 @@ export const buildDocContentGetter = ( logger.warn( `User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}` ); - return; + return toolError( + 'Doc Read Failed', + `You do not have permission to read document ${docId} in this workspace.` + ); } - const docMeta = await models.doc.getSnapshot(options.workspace, docId, { - select: { - createdAt: true, - updatedAt: true, - createdByUser: { - select: publicUserSelect, - }, - updatedByUser: { - select: publicUserSelect, - }, - }, - }); + const docMeta = await models.doc.getAuthors(options.workspace, docId); if (!docMeta) { - return; + return documentSyncPendingError(docId); } const content = await docReader.getDocMarkdown( @@ -53,7 +61,7 @@ export const buildDocContentGetter = ( true ); if (!content) { - return; + return documentSyncPendingError(docId); } return { @@ -69,8 +77,12 @@ export const buildDocContentGetter = ( return getDoc; }; +type DocReadToolResult = Awaited< + ReturnType> +>; + export const createDocReadTool = ( - getDoc: (targetId?: string) => Promise + getDoc: (targetId?: string) => Promise ) => { return defineTool({ description: @@ -81,13 +93,10 @@ export const createDocReadTool = ( execute: async ({ doc_id }) => { try { const doc = await getDoc(doc_id); - if (!doc) { - return; - } - return { ...doc }; + return isToolError(doc) ? doc : { ...doc }; } catch (err: any) { logger.error(`Failed to read the doc ${doc_id}`, err); - return toolError('Doc Read Failed', err.message); + return toolError('Doc Read Failed', err.message ?? String(err)); } }, }); diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-semantic-search.ts b/packages/backend/server/src/plugins/copilot/tools/doc-semantic-search.ts index f9a6676e05e98..1984733c798dc 100644 --- a/packages/backend/server/src/plugins/copilot/tools/doc-semantic-search.ts +++ b/packages/backend/server/src/plugins/copilot/tools/doc-semantic-search.ts @@ -7,6 +7,7 @@ import { clearEmbeddingChunk, type Models, } from '../../../models'; +import { workspaceSyncRequiredError } from './doc-sync'; import { toolError } from './error'; import { defineTool } from './tool'; import type { @@ -27,14 +28,24 @@ export const buildDocSearchGetter = ( signal?: AbortSignal ) => { if (!options || !query?.trim() || !options.user || !options.workspace) { - return `Invalid search parameters.`; + return toolError( + 'Doc Semantic Search Failed', + 'Missing workspace, user, or query for doc_semantic_search.' + ); + } + const workspace = await models.workspace.get(options.workspace); + if (!workspace) { + return workspaceSyncRequiredError(); } const canAccess = await ac .user(options.user) .workspace(options.workspace) .can('Workspace.Read'); if (!canAccess) - return 'You do not have permission to access this workspace.'; + return toolError( + 'Doc Semantic Search Failed', + 'You do not have permission to access this workspace.' + ); const [chunks, contextChunks] = await Promise.all([ context.matchWorkspaceAll(options.workspace, query, 10, signal), docContext?.matchFiles(query, 10, signal) ?? [], @@ -53,7 +64,7 @@ export const buildDocSearchGetter = ( fileChunks.push(...contextChunks); } if (!blobChunks.length && !docChunks.length && !fileChunks.length) { - return `No results found for "${query}".`; + return []; } const docIds = docChunks.map(c => ({ @@ -101,7 +112,7 @@ export const createDocSemanticSearchTool = ( searchDocs: ( query: string, signal?: AbortSignal - ) => Promise + ) => Promise> ) => { return defineTool({ description: diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-sync.ts b/packages/backend/server/src/plugins/copilot/tools/doc-sync.ts new file mode 100644 index 0000000000000..059bb7606b5aa --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/doc-sync.ts @@ -0,0 +1,13 @@ +import { toolError } from './error'; + +export const LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE = + 'This workspace is local-only and does not have AFFiNE Cloud sync enabled yet. Ask the user to enable workspace sync, then try again.'; + +export const DOCUMENT_SYNC_PENDING_MESSAGE = (docId: string) => + `Document ${docId} is not available on AFFiNE Cloud yet. Ask the user to wait for workspace sync to finish, then try again.`; + +export const workspaceSyncRequiredError = () => + toolError('Workspace Sync Required', LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE); + +export const documentSyncPendingError = (docId: string) => + toolError('Document Sync Pending', DOCUMENT_SYNC_PENDING_MESSAGE(docId)); diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts index 1c1b7755b1e1c..996c7b093902d 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts @@ -7,6 +7,8 @@ import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import type { ToolResult } from './tool-result-card'; +import { getToolErrorDisplayName, isToolError } from './tool-result-utils'; +import type { ToolError } from './type'; interface DocKeywordSearchToolCall { type: 'tool-call'; @@ -20,10 +22,7 @@ interface DocKeywordSearchToolResult { toolCallId: string; toolName: string; args: { query: string }; - result: Array<{ - title: string; - docId: string; - }>; + result: Array<{ title: string; docId: string }> | ToolError | null; } export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) { @@ -51,9 +50,23 @@ export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) { if (this.data.type !== 'tool-result') { return nothing; } + const result = this.data.result; + if (!result || isToolError(result)) { + return html``; + } let results: ToolResult[] = []; try { - results = this.data.result.map(item => ({ + results = result.map(item => ({ title: item.title, icon: PageIcon(), onClick: () => { @@ -69,7 +82,7 @@ export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) { console.error('Failed to parse result', err); } return html` { + return getToolErrorDisplayName(result, 'Document read failed', { + 'Workspace Sync Required': 'Enable workspace sync to read this document', + 'Document Sync Pending': 'Wait for document sync to finish', + }); +}; + export class DocReadResult extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor data!: DocReadToolCall | DocReadToolResult; @@ -49,18 +62,25 @@ export class DocReadResult extends WithDisposable(ShadowlessElement) { if (this.data.type !== 'tool-result') { return nothing; } + const result = this.data.result; + if (!result || isToolError(result)) { + return html``; + } // TODO: better markdown rendering return html` { - const docId = (this.data as DocReadToolResult).result.docId; + const docId = result.docId; if (!docId) { return; } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts index 3baddeae029a1..505c05ae38faa 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts @@ -7,6 +7,8 @@ import { html, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import type { DocDisplayConfig } from '../ai-chat-chips'; +import { getToolErrorDisplayName, isToolError } from './tool-result-utils'; +import type { ToolError } from './type'; interface DocSemanticSearchToolCall { type: 'tool-call'; @@ -20,10 +22,7 @@ interface DocSemanticSearchToolResult { toolCallId: string; toolName: string; args: { query: string }; - result: Array<{ - content: string; - docId: string; - }>; + result: Array<{ content: string; docId: string }> | ToolError | null; } function parseResultContent(content: string) { @@ -82,11 +81,25 @@ export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) { if (this.data.type !== 'tool-result') { return nothing; } + const result = this.data.result; + if (!result || isToolError(result)) { + return html``; + } return html` ({ ...parseResultContent(result.content), title: this.docDisplayService.getTitle(result.docId), diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-utils.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-utils.ts new file mode 100644 index 0000000000000..ca9d991f9b737 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-utils.ts @@ -0,0 +1,16 @@ +import type { ToolError } from './type'; + +export const isToolError = (result: unknown): result is ToolError => + !!result && + typeof result === 'object' && + 'type' in result && + (result as ToolError).type === 'error'; + +export const getToolErrorDisplayName = ( + result: ToolError | null, + fallback: string, + overrides: Record = {} +) => { + if (!result) return fallback; + return overrides[result.name] ?? result.name; +};