import { access, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ExtractStructured } from './graph-extractor' import { embeddingStorageProvider } from './embedding-provider-key' import { KnowledgeService } from './knowledge-service' import type { EmbeddingProvider, RerankProvider } from './types' import { UrlImporter } from './url-importer' const temporaryDirectories: string[] = [] const services: KnowledgeService[] = [] function createRealDocxFixture(): Buffer { return Buffer.from( zipSync({ '[Content_Types].xml': strToU8( '' + '' + '' + '' + '' + '' ), '_rels/.rels': strToU8( '' + '' + '' + '' ), 'word/document.xml': strToU8( '' + '' + '' + '蓝鲸发布流程' + '发布前必须完成审批和回归测试。' + '负责人' + '产品团队' + '' ), 'word/_rels/document.xml.rels': strToU8( '' + '' ) }) ) } function createRealPdfFixture(...pageTexts: string[]): Buffer { const texts = pageTexts.length > 0 ? pageTexts : [''] const fontObjectId = texts.length + 3 const firstContentObjectId = fontObjectId + 1 const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', `<< /Type /Pages /Kids [${texts .map((_, index) => `${index + 3} 0 R`) .join(' ')}] /Count ${texts.length} >>`, ...texts.map( (_, index) => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + `/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` + `/Contents ${firstContentObjectId + index} 0 R >>` ), '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', ...texts.map((text) => { const stream = `BT /F1 14 Tf 72 720 Td (${text}) Tj ET` return ( `<< /Length ${Buffer.byteLength(stream)} >>\n` + `stream\n${stream}\nendstream` ) }) ] let content = '%PDF-1.4\n' const offsets = [0] for (const [index, object] of objects.entries()) { offsets.push(Buffer.byteLength(content)) content += `${index + 1} 0 obj\n${object}\nendobj\n` } const xrefOffset = Buffer.byteLength(content) content += `xref\n0 ${objects.length + 1}\n` content += '0000000000 65535 f \n' content += offsets .slice(1) .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) .join('') content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` content += `startxref\n${xrefOffset}\n%%EOF\n` return Buffer.from(content) } async function createService( urlImporter?: UrlImporter, embeddingProvider?: EmbeddingProvider, extractStructured?: ExtractStructured, rerankProvider?: RerankProvider ): Promise<{ directory: string; service: KnowledgeService }> { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-')) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), urlImporter, embeddingProvider, extractStructured, rerankProvider }) await service.initialize() services.push(service) return { directory, service } } afterEach(async () => { await Promise.all(services.splice(0).map((service) => service.dispose())) await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) describe('KnowledgeService', () => { it('imports real DOCX and PDF files from disk into searchable chunks', async () => { const { directory, service } = await createService() const docxPath = join(directory, '蓝鲸发布手册.docx') const pdfPath = join(directory, 'Blue-Harbor-operations.pdf') await Promise.all([ writeFile(docxPath, createRealDocxFixture()), writeFile( pdfPath, createRealPdfFixture( 'Blue Harbor deployment requires a signed approval.', 'Rollback begins by restoring the verified package.' ) ) ]) const library = service.createLibrary({ name: '真实办公文档', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [docxPath, pdfPath]) const snapshot = service.snapshot(library.id) expect(snapshot.libraries[0]).toMatchObject({ documentCount: 2, indexedDocumentCount: 2, processingDocumentCount: 0, failedDocumentCount: 0 }) expect(snapshot.documents).toHaveLength(2) expect( snapshot.documents.every( (document) => document.status === 'ready' && document.textIndexStatus === 'ready' && document.vectorIndexStatus === 'disabled' ) ).toBe(true) const docxDocument = snapshot.documents.find( (document) => document.sourceLocation === docxPath ) const pdfDocument = snapshot.documents.find( (document) => document.sourceLocation === pdfPath ) expect(docxDocument).toBeDefined() expect(pdfDocument).toBeDefined() expect( service.getDocumentSource({ knowledgeBaseId: library.id, documentId: docxDocument!.id }) ).toMatchObject({ document: { sourceLocation: docxPath }, source: { knowledgeBaseId: library.id } }) const docxChunks = service.database.listChunks(docxDocument!.id) const pdfChunks = service.database.listChunks(pdfDocument!.id) expect(docxChunks.map((chunk) => chunk.content).join('\n')).toContain( '发布前必须完成审批和回归测试' ) expect(docxChunks.some((chunk) => chunk.location?.includes('表格'))).toBe( true ) expect(pdfChunks.map((chunk) => chunk.location)).toEqual([ '第 1 页', '第 2 页' ]) expect(service.search(library.id, '发布审批')[0]?.document.id).toBe( docxDocument!.id ) expect( service.search(library.id, 'verified package')[0]?.document.id ).toBe(pdfDocument!.id) }) it('uses document and query embedding roles without exposing providers to runtimes', async () => { const embed = vi.fn(async (input: readonly string[]) => input.map(() => [9, 9]) ) const embedDocuments = vi.fn(async (input: readonly string[]) => input.map(() => [1, 0]) ) const embedQuery = vi.fn(async (input: readonly string[]) => input.map(() => [1, 0]) ) const provider: EmbeddingProvider = { provider: 'openai-compatible', model: 'role-aware', embed, embedDocuments, embedQuery } const { directory, service } = await createService( undefined, provider ) const sourcePath = join(directory, 'role-aware.md') await writeFile(sourcePath, 'role aware knowledge content', 'utf8') const library = service.createLibrary({ name: 'Role-aware library', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) expect(embedDocuments).toHaveBeenCalled() expect(embed).not.toHaveBeenCalled() await service.retrieve({ knowledgeBaseId: library.id, query: 'role aware', settings: { ...library.retrievalSettings, ftsWeight: 0, vectorWeight: 1, graphWeight: 0 } }) expect(embedQuery).toHaveBeenCalledWith( ['role aware'], expect.any(AbortSignal) ) expect(embed).not.toHaveBeenCalled() }) it('rebuilds vectors only for the selected knowledge base', async () => { const embed = vi.fn(async (input: readonly string[]) => input.map(() => [1, 0]) ) const provider: EmbeddingProvider = { provider: 'openai-compatible', model: 'embed-v1', fingerprint: 'openai-compatible:https://safe.invalid:embed-v1', embed } const { directory, service } = await createService( undefined, provider ) const firstPath = join(directory, 'first.md') const secondPath = join(directory, 'second.md') await writeFile(firstPath, 'first vector document', 'utf8') await writeFile(secondPath, 'second vector document', 'utf8') const first = service.createLibrary({ name: 'First vector library', storageMode: 'reference', graphEnabled: false }) const second = service.createLibrary({ name: 'Second vector library', storageMode: 'reference', graphEnabled: false }) await service.importPaths(first.id, [firstPath]) await service.importPaths(second.id, [secondPath]) const firstDocument = service.snapshot(first.id).documents[0]! const secondDocument = service.snapshot(second.id).documents[0]! service.database.upsertDocument( { id: firstDocument.id, knowledgeBaseId: first.id, sourceId: firstDocument.sourceId, externalId: firstDocument.externalId, title: firstDocument.title, mimeType: firstDocument.mimeType, metadata: firstDocument.metadata }, [{ ordinal: 0, content: 'first changed content' }] ) service.database.upsertDocument( { id: secondDocument.id, knowledgeBaseId: second.id, sourceId: secondDocument.sourceId, externalId: secondDocument.externalId, title: secondDocument.title, mimeType: secondDocument.mimeType, metadata: secondDocument.metadata }, [{ ordinal: 0, content: 'second changed content' }] ) embed.mockClear() const started = await service.rebuildEmbeddingIndex(first.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) expect(started.knowledgeBaseId).toBe(first.id) await vi.waitFor(async () => { const status = await service.getEmbeddingIndexSnapshot(first.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) expect(status.indexStatus.job?.status).toBe('completed') }) expect(embed).toHaveBeenCalledTimes(1) expect( service.database.getEmbeddingIndexState( firstDocument.id, embeddingStorageProvider(provider), provider.model ) ).toMatchObject({ status: 'ready' }) expect( service.database.getEmbeddingIndexState( secondDocument.id, embeddingStorageProvider(provider), provider.model ) ).toBeUndefined() expect( ( await service.getEmbeddingIndexSnapshot(second.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) ).indexStatus.job ).toBeNull() }) it('cancels a vector rebuild only for its matching library job', async () => { let receivedSignal: AbortSignal | undefined const provider: EmbeddingProvider = { provider: 'openai-compatible', model: 'embed-v1', embed: (_input, signal) => new Promise((_resolve, reject) => { receivedSignal = signal signal?.addEventListener( 'abort', () => reject(signal.reason), { once: true } ) }) } const { directory, service } = await createService() const sourcePath = join(directory, 'cancel-vector.md') await writeFile(sourcePath, 'cancel this vector rebuild', 'utf8') const library = service.createLibrary({ name: 'Cancellable vector library', storageMode: 'reference', graphEnabled: false }) const other = service.createLibrary({ name: 'Other vector library', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) await service.setEmbeddingProvider(provider) const started = await service.rebuildEmbeddingIndex(library.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) const jobId = started.indexStatus.job?.id expect(jobId).toBeDefined() await vi.waitFor(() => { expect(receivedSignal).toBeDefined() }) expect( await service.cancelEmbeddingIndex(other.id, jobId!) ).toBe(false) expect( await service.cancelEmbeddingIndex(library.id, jobId!) ).toBe(true) await vi.waitFor(async () => { const status = await service.getEmbeddingIndexSnapshot(library.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) expect(status.indexStatus.job?.status).toBe('cancelled') }) expect(receivedSignal?.aborted).toBe(true) }) it('indexes referenced files and returns cited search results', async () => { const { directory, service } = await createService() const sourcePath = join(directory, '产品说明.md') await writeFile(sourcePath, '# GoodBuddy\n跨平台桌面智能助手', 'utf8') const library = service.createLibrary({ name: '产品知识', storageMode: 'reference', graphEnabled: false, graphStrategy: 'rules' }) await service.importPaths(library.id, [sourcePath]) const snapshot = service.snapshot(library.id) const results = service.search(library.id, '跨平台桌面') expect(snapshot.sources).toHaveLength(1) expect(snapshot.documents).toHaveLength(1) expect(snapshot.documents[0]?.status).toBe('ready') expect(results[0]?.document.title).toBe('产品说明') expect(results[0]?.source.location).toBe(sourcePath) await service.dispose() }) it('uses chunking changes made while an empty library first imports', async () => { let notifyParserStarted: (() => void) | undefined const parserStarted = new Promise((resolve) => { notifyParserStarted = resolve }) let releaseParser: (() => void) | undefined const parserReleased = new Promise((resolve) => { releaseParser = resolve }) const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-knowledge-service-') ) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), parseDocument: async (name) => { notifyParserStarted?.() await parserReleased return { title: name, content: '# Contextual\nfirst import content', sourceFormat: 'text', sections: [{ locator: '全文', content: '# Contextual\nfirst import content' }], warnings: [] } } }) await service.initialize() services.push(service) const sourcePath = join(directory, 'contextual.md') await writeFile(sourcePath, '# Contextual\nfirst import content', 'utf8') const library = service.createLibrary({ name: 'Contextual knowledge', storageMode: 'reference', graphEnabled: false }) const importing = service.importPaths(library.id, [sourcePath]) await parserStarted const updated = service.updateSettings({ knowledgeBaseId: library.id, chunking: { ...library.chunkingSettings, contextualIndexingEnabled: true } }) expect(updated.chunkingRebuildRequired).toBe(false) releaseParser?.() await importing const snapshot = service.snapshot(library.id) const document = snapshot.documents[0] expect(snapshot.libraries[0]?.chunkingRebuildRequired).toBe(false) expect(document).toBeDefined() expect( service.database.getEmbeddingIndexDocument(document!.id)?.items[0] ?.content ).toContain('[context ') }) it('copies managed directories and never deletes the original source', async () => { const { directory, service } = await createService() const original = join(directory, 'original') await mkdir(original) await writeFile(join(original, 'notes.txt'), '托管目录知识', 'utf8') const library = service.createLibrary({ name: '托管知识', storageMode: 'managed', graphEnabled: false, graphStrategy: 'rules' }) await service.importPaths(library.id, [original]) const [source] = service.snapshot(library.id).sources expect(source?.location).not.toBe(original) if (!source) { throw new Error('Managed source was not created') } await access(join(source.location, 'notes.txt')) await service.removeSource(source.id) await access(join(original, 'notes.txt')) await expect(access(source.location)).rejects.toThrow() await service.dispose() }) it('imports safe URLs through the validated importer', async () => { const importer = new UrlImporter({ lookup: async () => [{ address: '93.184.216.34', family: 4 }], transport: async () => ({ status: 200, headers: { 'content-type': 'text/html' }, body: Buffer.from( '帮助中心
安装与配置说明
' ) }) }) const { service } = await createService(importer) const library = service.createLibrary({ name: '网页知识', storageMode: 'managed', graphEnabled: false, graphStrategy: 'rules' }) await service.importUrl( library.id, 'https://example.com/help', new AbortController().signal ) expect(service.snapshot(library.id).sources[0]).toMatchObject({ type: 'url', status: 'ready', displayName: '帮助中心' }) expect(service.search(library.id, '安装配置')).not.toHaveLength(0) await service.dispose() }) it('extracts an optional local rule graph with evidence', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'architecture.md') await writeFile( sourcePath, 'GoodBuddy(产品)依赖 Electron(框架)。', 'utf8' ) const library = service.createLibrary({ name: '架构图谱', storageMode: 'reference', graphEnabled: true, graphStrategy: 'rules' }) await service.importPaths(library.id, [sourcePath]) const snapshot = service.snapshot(library.id) expect(snapshot.entities.length).toBeGreaterThan(0) expect(snapshot.evidence.length).toBeGreaterThan(0) expect(snapshot.tasks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'parsing', status: 'succeeded', progress: 100 }), expect.objectContaining({ kind: 'embedding', status: 'skipped', progress: 100 }), expect.objectContaining({ kind: 'graph', status: 'succeeded', progress: 100 }) ]) ) await service.dispose() }) it('publishes a new document when model graph extraction fails', async () => { const extractStructured = vi.fn(async () => { throw new Error('模型图谱抽取暂时不可用') }) const { directory, service } = await createService( undefined, undefined, extractStructured ) const sourcePath = join(directory, 'model-graph-failure.md') await writeFile( sourcePath, '# 可检索文档\n即使图谱失败,全文内容仍应可用。', 'utf8' ) const library = service.createLibrary({ name: '模型图谱容错', storageMode: 'reference', graphEnabled: true, graphStrategy: 'model' }) await expect( service.importPaths(library.id, [sourcePath]) ).resolves.toBeUndefined() const snapshot = service.snapshot(library.id) expect(snapshot.documents).toHaveLength(1) expect(service.search(library.id, '全文内容')).toHaveLength(1) expect(snapshot.tasks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'graph', status: 'failed', message: '模型图谱抽取暂时不可用' }), expect.objectContaining({ kind: 'parsing', status: 'succeeded' }) ]) ) }) it('reextracts graph evidence and removes only stale generated entities', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'reextract.md') await writeFile( sourcePath, 'GoodBuddy(产品)依赖 Electron(框架)。', 'utf8' ) const library = service.createLibrary({ name: '重新抽取', storageMode: 'reference', graphEnabled: true, graphStrategy: 'rules' }) await service.importPaths(library.id, [sourcePath]) const stale = service.database.createEntity({ knowledgeBaseId: library.id, name: '过期实体', type: '概念', locked: false }) const manual = service.database.createEntity({ knowledgeBaseId: library.id, name: '人工实体', type: '概念', locked: true }) await service.reextractGraph(library.id) const snapshot = service.snapshot(library.id) expect(snapshot.evidence.length).toBeGreaterThan(0) expect(service.database.getEntity(stale.id)).toBeUndefined() expect(service.database.getEntity(manual.id)).toBeDefined() }) it('prunes generated graph records after chunk evidence is removed', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'chunk-graph.md') await writeFile(sourcePath, '# Disposable Entity', 'utf8') const library = service.createLibrary({ name: 'Chunk graph cleanup', storageMode: 'reference', graphEnabled: true, graphStrategy: 'rules' }) await service.importPaths(library.id, [sourcePath]) const snapshot = service.snapshot(library.id) const document = snapshot.documents[0]! const generatedEntity = snapshot.entities.find( (entity) => entity.name === 'Disposable Entity' )! const evidence = snapshot.evidence.find( (item) => item.entityId === generatedEntity.id )! await service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: evidence.chunkId!, content: 'Replacement text without graph evidence' }) expect(service.database.getEntity(generatedEntity.id)).toBeUndefined() expect(service.snapshot(library.id).evidence).toEqual([]) }) it('keeps a committed chunk edit successful when graph refresh fails', async () => { const extractStructured = vi.fn(async () => { throw new Error('synthetic edit graph failure') }) const { directory, service } = await createService( undefined, undefined, extractStructured ) const sourcePath = join(directory, 'edit-graph-failure.md') await writeFile(sourcePath, 'initial edit content', 'utf8') const library = service.createLibrary({ name: 'Edit graph failure', storageMode: 'reference', graphEnabled: false, graphStrategy: 'model' }) await service.importPaths(library.id, [sourcePath]) service.database.updateKnowledgeBase(library.id, { graphEnabled: true }) const document = service.snapshot(library.id).documents[0]! const chunk = service.database .listChunks(document.id) .find((candidate) => candidate.role !== 'parent')! await expect(service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: chunk.id, content: 'committed edit survives graph failure' })).resolves.toMatchObject({ content: 'committed edit survives graph failure' }) await vi.waitFor(() => expect(extractStructured).toHaveBeenCalled()) expect(service.search(library.id, 'committed')).toHaveLength(1) }) it('coalesces edit graph refreshes so stale extraction cannot publish last', async () => { const resolvers: Array<(value: { entities: unknown[] relations: unknown[] }) => void> = [] const extractStructured: ExtractStructured = () => new Promise((resolve) => { resolvers.push(resolve) }) const { directory, service } = await createService( undefined, undefined, extractStructured ) const sourcePath = join(directory, 'coalesced-edit-graph.md') await writeFile(sourcePath, 'initial graph content', 'utf8') const library = service.createLibrary({ name: 'Coalesced edit graph', storageMode: 'reference', graphEnabled: false, graphStrategy: 'model' }) await service.importPaths(library.id, [sourcePath]) service.database.updateKnowledgeBase(library.id, { graphEnabled: true }) const document = service.snapshot(library.id).documents[0]! const chunk = service.database .listChunks(document.id) .find((candidate) => candidate.role !== 'parent')! await service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: chunk.id, content: 'first graph edit' }) await vi.waitFor(() => expect(resolvers).toHaveLength(1)) await service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: chunk.id, content: 'second graph edit' }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(resolvers).toHaveLength(1) resolvers.shift()?.({ entities: [], relations: [] }) await vi.waitFor(() => expect(resolvers).toHaveLength(1)) resolvers.shift()?.({ entities: [], relations: [] }) await vi.waitFor(() => { expect( service.snapshot(library.id).tasks.filter( (task) => task.kind === 'graph' && task.status === 'succeeded' ) ).toHaveLength(1) }) expect(service.database.listChunks(document.id)).toEqual( expect.arrayContaining([ expect.objectContaining({ content: 'second graph edit' }) ]) ) }) it('fails hybrid reextraction when model extraction fails', async () => { const extractStructured = vi.fn(async () => { throw new Error('模型未返回图谱内容') }) const { directory, service } = await createService( undefined, undefined, extractStructured ) const sourcePath = join(directory, 'hybrid-fallback.md') await writeFile(sourcePath, '# 本地实体', 'utf8') const library = service.createLibrary({ name: '混合抽取', storageMode: 'reference', graphEnabled: false, graphStrategy: 'hybrid' }) await service.importPaths(library.id, [sourcePath]) service.database.updateKnowledgeBase(library.id, { graphEnabled: true }) await expect(service.reextractGraph(library.id)).rejects.toThrow( '模型未返回图谱内容' ) expect(service.snapshot(library.id).entities).toHaveLength(0) expect(service.snapshot(library.id).tasks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'graph', status: 'failed', message: '模型未返回图谱内容' }) ]) ) }) it('indexes optional embeddings and performs vector-backed hybrid search', async () => { const provider: EmbeddingProvider = { provider: 'test-provider', model: 'test-model', embed: async (input) => input.map((text) => text.includes('orbital') || text === 'related meaning' ? [1, 0] : [0, 1] ) } const { directory, service } = await createService(undefined, provider) const sourcePath = join(directory, 'vectors.txt') await writeFile(sourcePath, 'orbital telescope notes', 'utf8') const library = service.createLibrary({ name: 'Vector knowledge', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const document = service.snapshot(library.id).documents[0] if (!document) { throw new Error('Indexed document missing') } expect( service.database.getEmbeddingIndexState( document.id, provider.provider, provider.model ) ).toMatchObject({ status: 'ready', dimensions: 2 }) const results = await service.searchHybrid( library.id, 'related meaning' ) expect(results[0]?.document.id).toBe(document.id) expect(results[0]?.retrieval.channels).toContain('vector') }) it('does not serve vectors from a different provider fingerprint', async () => { const firstProvider: EmbeddingProvider = { provider: 'openai-compatible', model: 'same-model', fingerprint: 'openai-compatible:https://one.invalid:same-model', embed: async (input) => input.map(() => [1, 0]) } const { directory, service } = await createService( undefined, firstProvider ) const sourcePath = join(directory, 'fingerprint.txt') await writeFile(sourcePath, 'fingerprint compatibility', 'utf8') const library = service.createLibrary({ name: 'Fingerprint knowledge', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const document = service.snapshot(library.id).documents[0]! expect( service.database.getEmbeddingIndexState( document.id, embeddingStorageProvider(firstProvider), firstProvider.model ) ).toMatchObject({ status: 'ready' }) expect( service.database.getEmbeddingIndexState( document.id, firstProvider.provider, firstProvider.model ) ).toBeUndefined() const firstResponse = await service.retrieve({ knowledgeBaseId: library.id, query: 'semantically related', settings: { ...library.retrievalSettings, ftsWeight: 0, vectorWeight: 1, graphWeight: 0, minimumVectorSimilarity: 0 } }) expect(firstResponse.diagnostics.vectorScannedCount).toBe(1) expect(firstResponse.results[0]?.channels).toContain('vector') const secondProvider: EmbeddingProvider = { ...firstProvider, fingerprint: 'openai-compatible:https://two.invalid:same-model' } await service.setEmbeddingProvider(secondProvider) const response = await service.retrieve({ knowledgeBaseId: library.id, query: 'fingerprint' }) expect(response.diagnostics.vectorScannedCount).toBe(0) expect(response.diagnostics.degradedChannels).toEqual( expect.arrayContaining([ expect.objectContaining({ channel: 'vector', reason: '当前向量模型没有可用的兼容索引。' }) ]) ) }) it('keeps FTS available and records diagnostics when embeddings fail', async () => { const provider: EmbeddingProvider = { provider: 'failing-provider', model: 'failing-model', embed: async () => { throw Object.assign( new Error('Bearer sk-private failed with private payload'), { status: 503 } ) } } const { directory, service } = await createService(undefined, provider) const sourcePath = join(directory, 'fallback.txt') await writeFile(sourcePath, 'lexical fallback remains searchable', 'utf8') const library = service.createLibrary({ name: 'Fallback knowledge', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const document = service.snapshot(library.id).documents[0] if (!document) { throw new Error('Indexed document missing') } expect(document.status).toBe('ready') expect(service.snapshot(library.id).sources[0]?.status).toBe('ready') expect(service.search(library.id, 'fallback')).toHaveLength(1) expect( service.database.getEmbeddingIndexState( document.id, provider.provider, provider.model ) ).toMatchObject({ status: 'error', lastError: '向量服务暂时不可用。' }) expect( JSON.stringify( service.database.getEmbeddingIndexState( document.id, provider.provider, provider.model ) ) ).not.toContain('sk-private') const results = await service.searchHybrid(library.id, 'fallback') expect(results[0]?.retrieval.channels).toContain('fts') }) it('bounds long indexing failures so task persistence does not mask them', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-knowledge-service-') ) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), parseDocument: async () => { throw new Error('x'.repeat(2_000)) } }) await service.initialize() services.push(service) const sourcePath = join(directory, 'failure.txt') await writeFile(sourcePath, 'failure source', 'utf8') const library = service.createLibrary({ name: 'Bounded task errors', storageMode: 'reference', graphEnabled: false }) await expect( service.importPaths(library.id, [sourcePath]) ).rejects.toThrow() const failedTasks = service .snapshot(library.id) .tasks.filter((task) => task.status === 'failed') expect(failedTasks.length).toBeGreaterThan(0) expect( failedTasks.every( (task) => (task.message?.length ?? 0) <= 1_000 && (task.error?.message.length ?? 0) <= 1_000 ) ).toBe(true) }) it('defers existing-document rebuilds when an embedding provider is enabled', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'existing.txt') await writeFile(sourcePath, 'existing semantic content', 'utf8') const library = service.createLibrary({ name: 'Existing knowledge', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const document = service.snapshot(library.id).documents[0]! const provider: EmbeddingProvider = { provider: 'late-provider', model: 'late-model', embed: async (input) => input.map(() => [0.5, 0.5]) } await service.setEmbeddingProvider(provider) expect( service.database.getEmbeddingIndexState( document.id, provider.provider, provider.model ) ).toBeUndefined() }) it('embeds a retrieval query once across multiple libraries', async () => { const embed = vi.fn( async (input) => input.map(() => [1, 0]) ) const provider: EmbeddingProvider = { provider: 'shared-query-provider', model: 'shared-query-model', embed } const { directory, service } = await createService(undefined, provider) const libraryIds: string[] = [] for (const index of [1, 2]) { const sourcePath = join(directory, `library-${index}.txt`) await writeFile(sourcePath, `shared topic ${index}`, 'utf8') const library = service.createLibrary({ name: `Library ${index}`, storageMode: 'reference', graphEnabled: false }) libraryIds.push(library.id) await service.importPaths(library.id, [sourcePath]) } embed.mockClear() const results = await service.retrieveMany( libraryIds, 'shared topic' ) expect(embed).toHaveBeenCalledOnce() expect(results).toHaveLength(2) expect( results.every((item) => item.response.results.length > 0) ).toBe(true) }) it('reranks multiple libraries concurrently while preserving input order', async () => { let releaseReranks: (() => void) | undefined let startedReranks = 0 const allStarted = new Promise((resolve) => { releaseReranks = resolve }) const rerank = vi.fn( async (_query, _documents, _topN, signal) => { startedReranks += 1 if (startedReranks === 2) { releaseReranks?.() } await allStarted signal?.throwIfAborted() return [{ index: 0, relevanceScore: 0.9 }] } ) const rerankProvider: RerankProvider = { provider: 'concurrency-test', model: 'concurrency-test-model', rerank } const { directory, service } = await createService( undefined, undefined, undefined, rerankProvider ) const libraryIds: string[] = [] for (const index of [1, 2]) { const sourcePath = join(directory, `rerank-library-${index}.txt`) await writeFile(sourcePath, `shared rerank topic ${index}`, 'utf8') const library = service.createLibrary({ name: `Rerank library ${index}`, storageMode: 'reference', graphEnabled: false }) libraryIds.push(library.id) await service.importPaths(library.id, [sourcePath]) service.updateSettings({ knowledgeBaseId: library.id, retrieval: { ...library.retrievalSettings, vectorWeight: 0, graphWeight: 0, rerankMode: 'learned', localRerankEnabled: true } }) } const results = await service.retrieveMany( libraryIds, 'shared rerank topic' ) expect(rerank).toHaveBeenCalledTimes(2) expect(results.map((item) => item.knowledgeBaseId)).toEqual(libraryIds) }) it('applies learned reranking with bounded candidates and score diagnostics', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'learned-rerank.txt') await writeFile( sourcePath, [ 'shared keyword first candidate', '', 'shared keyword preferred candidate' ].join('\n'), 'utf8' ) const library = service.createLibrary({ name: 'Learned rerank', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const original = service.snapshot(library.id).documents[0]! service.database.upsertDocument( { ...original, title: original.title }, [ { id: 'rerank-first', ordinal: 0, content: 'shared keyword first' }, { id: 'rerank-preferred', ordinal: 1, content: 'shared keyword preferred' } ] ) const rerank = vi.fn( async (_query, documents, topN) => { expect(documents).toHaveLength(2) expect(topN).toBe(2) return [ { index: 1, relevanceScore: 0.95 }, { index: 0, relevanceScore: 0.2 } ] } ) await service.setRerankProvider({ provider: 'cohere-compatible', model: 'rerank-test', rerank }) const response = await service.retrieve({ knowledgeBaseId: library.id, query: 'shared keyword', settings: { ...library.retrievalSettings, topK: 2, candidateMultiplier: 2, vectorWeight: 0, graphWeight: 0, rerankMode: 'learned', localRerankEnabled: true } }) expect(rerank).toHaveBeenCalledOnce() expect(response.results.map((result) => result.chunkId)).toEqual([ 'rerank-preferred', 'rerank-first' ]) expect(response.results[0]).toMatchObject({ preRerankRank: 2, relevance: 0.95, scores: { rerankScore: 0.95 } }) expect(response.diagnostics.rerank).toMatchObject({ requested: 'learned', used: 'learned', status: 'applied', candidateCount: 2, model: 'rerank-test' }) }) it('falls back locally on learned rerank failure and propagates cancellation', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'rerank-fallback.txt') await writeFile(sourcePath, 'fallback keyword content', 'utf8') const library = service.createLibrary({ name: 'Rerank fallback', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) await service.setRerankProvider({ provider: 'cohere-compatible', model: 'rerank-test', rerank: async () => { throw Object.assign(new Error('private provider response'), { status: 503 }) } }) const fallback = await service.retrieve({ knowledgeBaseId: library.id, query: 'fallback keyword', settings: { ...library.retrievalSettings, vectorWeight: 0, graphWeight: 0, rerankMode: 'learned', localRerankEnabled: true } }) expect(fallback.results).toHaveLength(1) expect(fallback.diagnostics.rerank).toMatchObject({ requested: 'learned', used: 'local', status: 'fallback', reason: '重排服务暂时不可用。' }) const controller = new AbortController() await service.setRerankProvider({ provider: 'cohere-compatible', model: 'rerank-test', rerank: async (_query, _documents, _topN, signal) => { controller.abort(new Error('cancel learned rerank')) signal?.throwIfAborted() return [] } }) await expect( service.retrieve( { knowledgeBaseId: library.id, query: 'fallback keyword', settings: { ...library.retrievalSettings, vectorWeight: 0, graphWeight: 0, rerankMode: 'learned', localRerankEnabled: true } }, controller.signal ) ).rejects.toThrow('cancel learned rerank') }) it('returns explicit degradation diagnostics and enforces context budgets', async () => { const provider: EmbeddingProvider = { provider: 'offline-provider', model: 'offline-model', embed: async () => { throw Object.assign(new Error('private provider response'), { status: 503 }) } } const { directory, service } = await createService(undefined, provider) const sourcePath = join(directory, 'budget.txt') await writeFile( sourcePath, Array.from( { length: 500 }, (_, index) => `预算检索内容 ${index},这是用于上下文限制的说明。` ).join('\n'), 'utf8' ) const library = service.createLibrary({ name: 'Budget', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) service.updateSettings({ knowledgeBaseId: library.id, retrieval: { ...library.retrievalSettings, contextMaxCharacters: 2_000, topK: 6, adjacentChunkCount: 1, localRerankEnabled: true } }) const response = await service.retrieve({ knowledgeBaseId: library.id, query: '预算检索内容' }) expect(response.results.length).toBeGreaterThan(0) expect(response.context.characterCount).toBeLessThanOrEqual(2_000) expect(response.context.truncated).toBe(true) expect(response.results[0]?.preRerankRank).toBeDefined() expect(response.diagnostics.degradedChannels).toEqual( expect.arrayContaining([ expect.objectContaining({ channel: 'vector' }), expect.objectContaining({ channel: 'graph' }) ]) ) expect(JSON.stringify(response)).not.toContain('private provider response') }) it('rebuilds one document from its scoped source', async () => { const { directory, service } = await createService() const firstPath = join(directory, 'first.txt') const secondPath = join(directory, 'second.txt') await writeFile(firstPath, 'first old content', 'utf8') await writeFile(secondPath, 'second stable content', 'utf8') const library = service.createLibrary({ name: 'Scoped rebuild', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [firstPath, secondPath]) const documents = service.snapshot(library.id).documents const first = documents.find((document) => document.sourceLocation?.endsWith('first.txt') )! const second = documents.find((document) => document.sourceLocation?.endsWith('second.txt') )! const secondChecksum = second.checksum await writeFile(firstPath, 'first rebuilt content', 'utf8') await service.rebuildDocument({ knowledgeBaseId: library.id, documentId: first.id }) expect(service.search(library.id, 'rebuilt')[0]?.document.id).toBe(first.id) expect(service.database.getDocument(second.id)?.checksum).toBe( secondChecksum ) }) it('preserves old chunks, vectors, and graph evidence when rebuild extraction fails', async () => { let failExtraction = false const extractStructured: ExtractStructured = async () => { if (failExtraction) { throw new Error('synthetic graph failure') } return { entities: [], relations: [] } } const provider: EmbeddingProvider = { provider: 'atomic-provider', model: 'atomic-model', embed: async (input) => input.map(() => [1, 0]) } const { directory, service } = await createService( undefined, provider, extractStructured ) const sourcePath = join(directory, 'atomic-rebuild.md') await writeFile(sourcePath, '# Original Entity\nold searchable text', 'utf8') const library = service.createLibrary({ name: 'Atomic rebuild', storageMode: 'reference', graphEnabled: true, graphStrategy: 'hybrid' }) await service.importPaths(library.id, [sourcePath]) const before = service.snapshot(library.id) const document = before.documents[0]! const oldChunk = service.database.listChunks(document.id)[0]! const oldEvidence = before.evidence failExtraction = true await writeFile(sourcePath, '# Replacement Entity\nnew searchable text', 'utf8') await expect(service.rebuildDocument({ knowledgeBaseId: library.id, documentId: document.id })).rejects.toThrow('synthetic graph failure') expect(service.search(library.id, 'old')[0]?.chunk.id).toBe(oldChunk.id) expect(service.search(library.id, 'new')).toEqual([]) expect(service.database.vectorSearch({ knowledgeBaseId: library.id, provider: embeddingStorageProvider(provider), model: provider.model, vector: [1, 0], limit: 1 })[0]?.chunk.id).toBe(oldChunk.id) expect(service.snapshot(library.id).evidence).toEqual(oldEvidence) }) it('serializes a rebuild and chunk edit so the latest mutation wins', async () => { let releaseParser: (() => void) | undefined let parserStarted: (() => void) | undefined const started = new Promise((resolve) => { parserStarted = resolve }) let blockParser = false const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-knowledge-service-') ) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), parseDocument: async (name, buffer) => { if (blockParser) { parserStarted?.() await new Promise((resolve) => { releaseParser = resolve }) } const content = blockParser ? 'rebuilt content' : buffer.toString('utf8') return { title: name, content, sourceFormat: 'text', sections: [{ locator: '全文', content }], warnings: [] } } }) await service.initialize() services.push(service) const sourcePath = join(directory, 'mutation-gate.txt') await writeFile(sourcePath, 'initial content', 'utf8') const library = service.createLibrary({ name: 'Mutation gate', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const document = service.snapshot(library.id).documents[0]! const originalChunk = service.database .listChunks(document.id) .find((chunk) => chunk.role !== 'parent')! blockParser = true await writeFile(sourcePath, 'rebuilt source bytes', 'utf8') const rebuilding = service.rebuildDocument({ knowledgeBaseId: library.id, documentId: document.id }) await started const editing = service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: originalChunk.id, content: 'manual latest content' }) releaseParser?.() await rebuilding await expect(editing).rejects.toThrow( 'Chunk must belong to the requested document' ) expect(service.search(library.id, 'rebuilt')).toHaveLength(1) expect(service.search(library.id, 'manual')).toEqual([]) }) it('aborts standalone document rebuild work and keeps child capabilities honest', async () => { let parserSignal: AbortSignal | undefined const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-knowledge-service-') ) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), parseDocument: async (_name, _buffer, _purpose, signal) => { parserSignal = signal return new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) }) } }) await service.initialize() services.push(service) const sourcePath = join(directory, 'cancel-rebuild.txt') await writeFile(sourcePath, 'initial content', 'utf8') const library = service.createLibrary({ name: 'Cancel rebuild', storageMode: 'reference', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: sourcePath, displayName: 'cancel-rebuild.txt', status: 'ready' }) const document = service.database.upsertDocument( { knowledgeBaseId: library.id, sourceId: source.id, externalId: 'cancel-rebuild.txt', title: 'Cancel rebuild', sourceLocation: sourcePath }, [{ ordinal: 0, content: 'old content' }] ) const rebuilding = service.rebuildDocument({ knowledgeBaseId: library.id, documentId: document.id }) await vi.waitFor(() => expect(parserSignal).toBeDefined()) const task = service .snapshot(library.id) .tasks.find((candidate) => candidate.kind === 'document-rebuild')! expect(task.canCancel).toBe(true) expect(await service.cancelTask(task.id)).toBe(true) await expect(rebuilding).rejects.toBeDefined() expect(parserSignal?.aborted).toBe(true) expect(service.database.getKnowledgeTask(task.id)).toMatchObject({ status: 'cancelled', canCancel: false, canRetry: true }) }) it('deduplicates concurrent standalone document rebuild execution', async () => { let releaseParser: (() => void) | undefined const parserStarted = vi.fn() const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-knowledge-service-') ) temporaryDirectories.push(directory) const service = new KnowledgeService({ databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'), parseDocument: async (name) => { parserStarted() await new Promise((resolve) => { releaseParser = resolve }) return { title: name, content: 'rebuilt once', sourceFormat: 'text', sections: [], warnings: [] } } }) await service.initialize() services.push(service) const sourcePath = join(directory, 'dedupe-rebuild.txt') await writeFile(sourcePath, 'initial content', 'utf8') const library = service.createLibrary({ name: 'Dedupe rebuild', storageMode: 'reference', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'file', location: sourcePath, displayName: 'dedupe-rebuild.txt', status: 'ready' }) const document = service.database.upsertDocument( { knowledgeBaseId: library.id, sourceId: source.id, externalId: 'dedupe-rebuild.txt', title: 'Dedupe rebuild', sourceLocation: sourcePath }, [{ ordinal: 0, content: 'old content' }] ) const first = service.rebuildDocument({ knowledgeBaseId: library.id, documentId: document.id }) await vi.waitFor(() => expect(parserStarted).toHaveBeenCalledTimes(1)) const second = service.rebuildDocument({ knowledgeBaseId: library.id, documentId: document.id }) expect( service .snapshot(library.id) .tasks.filter((task) => task.kind === 'document-rebuild') ).toHaveLength(1) releaseParser?.() const [firstResult, secondResult] = await Promise.all([first, second]) expect(firstResult.id).toBe(document.id) expect(secondResult.id).toBe(document.id) expect(parserStarted).toHaveBeenCalledTimes(1) }) it('cancels and awaits active source sync before removing the source', async () => { let importerSignal: AbortSignal | undefined const importer = { import: vi.fn((_input: string, signal: AbortSignal) => { importerSignal = signal return new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) }) } as unknown as UrlImporter const { service } = await createService(importer) const library = service.createLibrary({ name: 'Delete syncing source', storageMode: 'managed', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'url', location: 'https://example.com/syncing', displayName: 'Syncing URL', status: 'ready' }) const syncing = service.syncSource(source.id) void syncing.catch(() => undefined) await vi.waitFor(() => expect(importerSignal).toBeDefined()) await expect(service.removeSource(source.id)).resolves.toBe(true) await expect(syncing).rejects.toBeDefined() expect(importerSignal?.aborted).toBe(true) expect(service.database.getSource(source.id)).toBeUndefined() }) it('leaves a paused source paused after aborting its active sync', async () => { let importerSignal: AbortSignal | undefined const importer = { import: vi.fn((_input: string, signal: AbortSignal) => { importerSignal = signal return new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) }) } as unknown as UrlImporter const { service } = await createService(importer) const library = service.createLibrary({ name: 'Pause syncing source', storageMode: 'managed', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'url', location: 'https://example.com/pausing', displayName: 'Pausing URL', status: 'ready' }) const syncing = service.syncSource(source.id) void syncing.catch(() => undefined) await vi.waitFor(() => expect(importerSignal).toBeDefined()) service.pauseSource(source.id) await expect(syncing).rejects.toBeDefined() expect(service.database.getSource(source.id)).toMatchObject({ status: 'paused', lastError: undefined }) }) it('marks an existing URL source failed when refresh fails before parsing', async () => { const importer = { import: vi.fn(async () => { throw new Error('URL refresh unavailable') }) } as unknown as UrlImporter const { service } = await createService(importer) const library = service.createLibrary({ name: 'Failed URL refresh', storageMode: 'managed', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'url', location: 'https://example.com/failure', displayName: 'Existing URL', status: 'ready' }) await expect(service.syncSource(source.id)).rejects.toThrow( 'URL refresh unavailable' ) expect(service.database.getSource(source.id)).toMatchObject({ status: 'error', lastError: 'URL refresh unavailable' }) }) it('preserves refreshed URL metadata when later indexing fails', async () => { const importer = { import: vi.fn(async () => ({ url: 'https://example.com/redirected', title: 'Redirected title', contentType: 'text/html', etag: 'fresh-etag', lastModified: undefined, discoveredUrls: [], document: { title: 'Redirected title', sourceFormat: '.html', content: 'refreshed content', sections: [{ locator: '网页正文', content: 'refreshed content' }], warnings: [] } })) } as unknown as UrlImporter const { service } = await createService(importer) const library = service.createLibrary({ name: 'Failed refreshed URL', storageMode: 'managed', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'url', location: 'https://example.com/original', displayName: 'Original title', status: 'ready' }) vi.spyOn(service.database, 'publishDocument').mockImplementationOnce( () => { throw new Error('synthetic indexing failure') } ) await expect(service.syncSource(source.id)).rejects.toThrow( 'synthetic indexing failure' ) expect(service.database.getSource(source.id)).toMatchObject({ location: 'https://example.com/redirected', displayName: 'Redirected title', status: 'error', metadata: expect.objectContaining({ etag: 'fresh-etag' }) }) }) it('serializes background embedding reindexes and awaits the rerun on disposal', async () => { const resolvers: Array<() => void> = [] let activeEmbeddings = 0 let maximumActiveEmbeddings = 0 const provider: EmbeddingProvider = { provider: 'serial-background-provider', model: 'serial-background-model', embed: vi.fn(async () => { activeEmbeddings += 1 maximumActiveEmbeddings = Math.max( maximumActiveEmbeddings, activeEmbeddings ) await new Promise((resolve) => { resolvers.push(resolve) }) activeEmbeddings -= 1 return [[1, 0]] }) } const { directory, service } = await createService() const sourcePath = join(directory, 'serial-background.txt') await writeFile(sourcePath, 'initial content', 'utf8') const library = service.createLibrary({ name: 'Serialized background embeddings', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) await service.setEmbeddingProvider(provider) const document = service.snapshot(library.id).documents[0]! const chunk = service.database.listChunks(document.id, 1)[0]! await service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: chunk.id, content: 'first edit' }) await vi.waitFor(() => expect(resolvers).toHaveLength(1)) await service.updateChunk({ knowledgeBaseId: library.id, documentId: document.id, chunkId: chunk.id, content: 'second edit' }) await new Promise((resolve) => setTimeout(resolve, 300)) expect(maximumActiveEmbeddings).toBe(1) resolvers.shift()?.() await vi.waitFor(() => expect(resolvers).toHaveLength(1)) const disposing = service.dispose() let disposed = false void disposing.then(() => { disposed = true }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(disposed).toBe(false) resolvers.shift()?.() await disposing expect(maximumActiveEmbeddings).toBe(1) }) it('cancels and awaits active graph work before deleting a library', async () => { let extractionSignal: AbortSignal | undefined const extractStructured: ExtractStructured = (_prompt, signal) => { extractionSignal = signal return new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) }) } const { directory, service } = await createService( undefined, undefined, extractStructured ) const sourcePath = join(directory, 'delete-library-graph.md') await writeFile(sourcePath, 'GoodBuddy depends on Electron.', 'utf8') const library = service.createLibrary({ name: 'Delete active graph library', storageMode: 'reference', graphEnabled: false, graphStrategy: 'model' }) await service.importPaths(library.id, [sourcePath]) service.database.updateKnowledgeBase(library.id, { graphEnabled: true }) const rebuilding = service.reextractGraph(library.id) void rebuilding.catch(() => undefined) await vi.waitFor(() => expect(extractionSignal).toBeDefined()) await expect(service.deleteLibrary(library.id)).resolves.toBe(true) await expect(rebuilding).rejects.toBeDefined() expect(extractionSignal?.aborted).toBe(true) expect(service.database.getKnowledgeBase(library.id)).toBeUndefined() }) it('retries source sync with one top-level lineage row and linked children', async () => { const { directory, service } = await createService() const sourcePath = join(directory, 'retry-source.txt') await writeFile(sourcePath, 'retry source content', 'utf8') const library = service.createLibrary({ name: 'Retry source', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) const source = service.snapshot(library.id).sources[0]! const original = service.database.createKnowledgeTask({ libraryId: library.id, sourceId: source.id, documentName: source.displayName, scope: 'source', kind: 'source-sync', status: 'failed', error: { message: 'synthetic retryable failure' } }) await service.retryTask(original.id) const tasks = service.snapshot(library.id).tasks const retries = tasks.filter( (task) => task.kind === 'source-sync' && task.retryOfTaskId === original.id ) expect(retries).toHaveLength(1) expect(retries[0]).toMatchObject({ attempt: original.attempt + 1, status: 'succeeded' }) expect( tasks.filter((task) => task.parentTaskId === retries[0]?.id).length ).toBeGreaterThan(0) expect( tasks .filter((task) => task.parentTaskId) .every((task) => !task.canCancel && !task.canRetry) ).toBe(true) }) it('deduplicates a source retry against an active manual sync', async () => { let releaseImport: (() => void) | undefined const importer = { import: vi.fn( async () => { await new Promise((resolve) => { releaseImport = resolve }) throw new Error('synthetic sync failure') } ) } as unknown as UrlImporter const { service } = await createService(importer) const library = service.createLibrary({ name: 'Dedupe source retry', storageMode: 'managed', graphEnabled: false }) const source = service.database.upsertSource({ knowledgeBaseId: library.id, type: 'url', location: 'https://example.com/dedupe', displayName: 'Dedupe URL', status: 'ready' }) const failed = service.database.createKnowledgeTask({ libraryId: library.id, sourceId: source.id, documentName: source.displayName, scope: 'source', kind: 'source-sync', status: 'failed', error: { message: 'retry me' } }) const syncing = service.syncSource(source.id) void syncing.catch(() => undefined) await vi.waitFor(() => expect(importer.import).toHaveBeenCalledOnce()) const retrying = service.retryTask(failed.id) void retrying.catch(() => undefined) await new Promise((resolve) => setTimeout(resolve, 0)) expect(importer.import).toHaveBeenCalledOnce() releaseImport?.() await expect(syncing).rejects.toThrow('synthetic sync failure') await expect(retrying).rejects.toThrow('synthetic sync failure') }) it('reconciles embedding task status during ordinary snapshots', async () => { let resolveEmbedding: ((value: number[][]) => void) | undefined const provider: EmbeddingProvider = { provider: 'snapshot-provider', model: 'snapshot-model', embed: () => new Promise((resolve) => { resolveEmbedding = resolve }) } const { directory, service } = await createService() const sourcePath = join(directory, 'snapshot-embedding.txt') await writeFile(sourcePath, 'snapshot embedding content', 'utf8') const library = service.createLibrary({ name: 'Snapshot embedding', storageMode: 'reference', graphEnabled: false }) await service.importPaths(library.id, [sourcePath]) await service.setEmbeddingProvider(provider) await service.rebuildEmbeddingIndex(library.id, { provider: provider.provider, model: provider.model, credentialConfigured: false }) await vi.waitFor(() => expect(resolveEmbedding).toBeDefined()) expect( service .snapshot(library.id) .tasks.find((task) => task.kind === 'embedding-rebuild') ).toMatchObject({ status: 'running', canCancel: true }) resolveEmbedding?.([[1, 0]]) await vi.waitFor(() => { expect( service .snapshot(library.id) .tasks.find((task) => task.kind === 'embedding-rebuild') ).toMatchObject({ status: 'succeeded', progress: 100 }) }) }) })