/** * Combined Retrieval — B1 tests. * * Tests the merge engine in isolation. All dependencies are mocked: * - MemorySearchLike (workspace + personal HybridSearch) * - KvarkClientLike (KVARK HTTP client) * * No server, no vault, no DB, no wiring. */ import { describe, it, expect, vi } from 'vitest'; import { CombinedRetrieval, mapMemoryResult, mapKvarkResult, hasSufficientLocalCoverage, shouldQueryKvark, type MemorySearchLike, type MemorySearchResultLike, type CombinedResult, } from '../src/combined-retrieval.js'; import type { KvarkClientLike, KvarkSearchResponseLike } from '../src/kvark-tools.js'; // ── Factories ───────────────────────────────────────────────────────────── function makeMemoryResult( overrides: { id?: number; content?: string; score?: number; frameType?: string; importance?: string } = {}, ): MemorySearchResultLike { return { frame: { id: overrides.id ?? 1, content: overrides.content ?? 'Some memory content', frame_type: overrides.frameType ?? 'P', importance: overrides.importance ?? 'normal', }, finalScore: overrides.score ?? 0.5, }; } function makeSearchMock(results: MemorySearchResultLike[] = []): MemorySearchLike { return { search: vi.fn(async () => results) }; } const KVARK_RESPONSE: KvarkSearchResponseLike = { results: [ { document_id: 42, title: 'Project Status Update', snippet: 'API review postponed.', score: 0.92, document_type: 'pdf' }, { document_id: 108, title: 'Q1 Budget', snippet: 'Engineering budget increased.', score: 0.87, document_type: 'spreadsheet' }, ], total: 8, query: 'project', }; function makeKvarkClient(overrides?: Partial): KvarkClientLike { return { search: overrides?.search ?? vi.fn(async () => KVARK_RESPONSE), askDocument: overrides?.askDocument ?? vi.fn(async () => ({ answer: '', sources: [] })), }; } // ── Helper unit tests ───────────────────────────────────────────────────── describe('mapMemoryResult', () => { it('maps workspace memory result with correct attribution', () => { const input = makeMemoryResult({ id: 7, content: 'decision made', score: 0.85, frameType: 'I', importance: 'important' }); const result = mapMemoryResult(input, 'workspace'); expect(result.source).toBe('workspace'); expect(result.attribution).toBe('[workspace memory]'); expect(result.content).toBe('decision made'); expect(result.score).toBe(0.85); expect(result.metadata.frameId).toBe(7); expect(result.metadata.frameType).toBe('I'); expect(result.metadata.importance).toBe('important'); }); it('maps personal memory result with correct attribution', () => { const input = makeMemoryResult({ id: 3, content: 'user prefers bullets' }); const result = mapMemoryResult(input, 'personal'); expect(result.source).toBe('personal'); expect(result.attribution).toBe('[personal memory]'); expect(result.content).toBe('user prefers bullets'); }); }); describe('mapKvarkResult', () => { it('preserves attribution from parseSearchResults', () => { const structured = { content: 'API review postponed.', documentId: 42, title: 'Project Status Update', score: 0.92, documentType: 'pdf' as string | null, attribution: '[KVARK: pdf: Project Status Update]', }; const result = mapKvarkResult(structured); expect(result.source).toBe('kvark'); expect(result.attribution).toBe('[KVARK: pdf: Project Status Update]'); expect(result.score).toBe(0.92); expect(result.metadata.documentId).toBe(42); expect(result.metadata.documentType).toBe('pdf'); }); }); describe('hasSufficientLocalCoverage', () => { it('returns true when 3+ results score >= 0.7', () => { const results: CombinedResult[] = [ { content: '', source: 'workspace', attribution: '', score: 0.9, metadata: {} }, { content: '', source: 'workspace', attribution: '', score: 0.8, metadata: {} }, { content: '', source: 'personal', attribution: '', score: 0.7, metadata: {} }, ]; expect(hasSufficientLocalCoverage(results)).toBe(true); }); it('returns false when fewer than 3 results score >= 0.7', () => { const results: CombinedResult[] = [ { content: '', source: 'workspace', attribution: '', score: 0.9, metadata: {} }, { content: '', source: 'workspace', attribution: '', score: 0.8, metadata: {} }, { content: '', source: 'personal', attribution: '', score: 0.5, metadata: {} }, ]; expect(hasSufficientLocalCoverage(results)).toBe(false); }); it('returns false for empty results', () => { expect(hasSufficientLocalCoverage([])).toBe(false); }); }); describe('shouldQueryKvark', () => { const weakLocal: CombinedResult[] = [ { content: '', source: 'workspace', attribution: '', score: 0.3, metadata: {} }, ]; it('returns false when kvarkClient is null', () => { expect(shouldQueryKvark(null, 'all', weakLocal)).toBe(false); }); it('returns false when scope is personal', () => { expect(shouldQueryKvark(makeKvarkClient(), 'personal', weakLocal)).toBe(false); }); it('returns false when scope is workspace', () => { expect(shouldQueryKvark(makeKvarkClient(), 'workspace', weakLocal)).toBe(false); }); it('returns false when local coverage is sufficient', () => { const strong: CombinedResult[] = [ { content: '', source: 'workspace', attribution: '', score: 0.9, metadata: {} }, { content: '', source: 'workspace', attribution: '', score: 0.8, metadata: {} }, { content: '', source: 'personal', attribution: '', score: 0.75, metadata: {} }, ]; expect(shouldQueryKvark(makeKvarkClient(), 'all', strong)).toBe(false); }); it('returns true when client exists, scope=all, and local coverage insufficient', () => { expect(shouldQueryKvark(makeKvarkClient(), 'all', weakLocal)).toBe(true); }); }); // ── CombinedRetrieval class tests ───────────────────────────────────────── describe('CombinedRetrieval', () => { it('returns workspace + personal results when kvarkClient is null', async () => { const wsResults = [makeMemoryResult({ id: 1, content: 'ws fact', score: 0.9 })]; const pResults = [makeMemoryResult({ id: 2, content: 'personal fact', score: 0.8 })]; const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock(wsResults), personalSearch: makeSearchMock(pResults), kvarkClient: null, }); const result = await cr.search('test'); expect(result.workspaceResults).toHaveLength(1); expect(result.personalResults).toHaveLength(1); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkAvailable).toBe(false); expect(result.kvarkSkipped).toBe(false); // not available, so not "skipped" expect(result.kvarkError).toBeUndefined(); }); it('searches only personal memory when scope=personal', async () => { const wsMock = makeSearchMock([makeMemoryResult({ id: 1 })]); const pMock = makeSearchMock([makeMemoryResult({ id: 2, content: 'personal only' })]); const cr = new CombinedRetrieval({ workspaceSearch: wsMock, personalSearch: pMock, kvarkClient: makeKvarkClient(), }); const result = await cr.search('test', { scope: 'personal' }); expect(result.workspaceResults).toHaveLength(0); expect(result.personalResults).toHaveLength(1); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkSkipped).toBe(true); expect(wsMock.search).not.toHaveBeenCalled(); }); it('searches only workspace memory when scope=workspace', async () => { const wsMock = makeSearchMock([makeMemoryResult({ id: 1, content: 'ws only' })]); const pMock = makeSearchMock([makeMemoryResult({ id: 2 })]); const cr = new CombinedRetrieval({ workspaceSearch: wsMock, personalSearch: pMock, kvarkClient: makeKvarkClient(), }); const result = await cr.search('test', { scope: 'workspace' }); expect(result.workspaceResults).toHaveLength(1); expect(result.personalResults).toHaveLength(0); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkSkipped).toBe(true); expect(pMock.search).not.toHaveBeenCalled(); }); it('calls KVARK when local results are insufficient', async () => { const searchFn = vi.fn(async () => KVARK_RESPONSE); const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock([makeMemoryResult({ score: 0.3 })]), personalSearch: makeSearchMock([]), kvarkClient: makeKvarkClient({ search: searchFn }), }); const result = await cr.search('project'); expect(searchFn).toHaveBeenCalledWith('project', { limit: 10 }); expect(result.kvarkResults).toHaveLength(2); expect(result.kvarkResults[0].source).toBe('kvark'); expect(result.kvarkResults[0].attribution).toBe('[KVARK: pdf: Project Status Update]'); expect(result.kvarkResults[1].attribution).toBe('[KVARK: spreadsheet: Q1 Budget]'); expect(result.kvarkAvailable).toBe(true); expect(result.kvarkSkipped).toBe(false); }); it('skips KVARK when local results are sufficient', async () => { const searchFn = vi.fn(async () => KVARK_RESPONSE); const strongResults = [ makeMemoryResult({ id: 1, score: 0.9 }), makeMemoryResult({ id: 2, score: 0.85 }), makeMemoryResult({ id: 3, score: 0.75 }), ]; const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock(strongResults), personalSearch: makeSearchMock([]), kvarkClient: makeKvarkClient({ search: searchFn }), }); const result = await cr.search('test'); expect(searchFn).not.toHaveBeenCalled(); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkAvailable).toBe(true); expect(result.kvarkSkipped).toBe(true); }); it('degrades gracefully when KVARK throws an Error', async () => { const searchFn = vi.fn(async () => { throw new Error('KVARK is down'); }); const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock([makeMemoryResult({ id: 1, score: 0.4 })]), personalSearch: makeSearchMock([makeMemoryResult({ id: 2, score: 0.3 })]), kvarkClient: makeKvarkClient({ search: searchFn }), }); const result = await cr.search('test'); expect(result.workspaceResults).toHaveLength(1); expect(result.personalResults).toHaveLength(1); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkError).toBe('KVARK is down'); expect(result.kvarkAvailable).toBe(true); expect(result.kvarkSkipped).toBe(false); }); it('captures non-Error throws from KVARK as generic message', async () => { const searchFn = vi.fn(async () => { throw 'string error'; }); const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock([]), personalSearch: makeSearchMock([]), kvarkClient: makeKvarkClient({ search: searchFn }), }); const result = await cr.search('test'); expect(result.kvarkError).toBe('Unknown KVARK error'); expect(result.kvarkResults).toHaveLength(0); }); it('works correctly when no workspace search is available', async () => { const cr = new CombinedRetrieval({ workspaceSearch: null, personalSearch: makeSearchMock([makeMemoryResult({ id: 1, content: 'personal fact', score: 0.6 })]), kvarkClient: makeKvarkClient(), }); const result = await cr.search('test'); expect(result.workspaceResults).toHaveLength(0); expect(result.personalResults).toHaveLength(1); expect(result.kvarkResults).toHaveLength(2); expect(result.kvarkAvailable).toBe(true); expect(result.kvarkSkipped).toBe(false); }); it('passes limit and profile through to memory search', async () => { const wsMock = makeSearchMock([]); const pMock = makeSearchMock([]); const cr = new CombinedRetrieval({ workspaceSearch: wsMock, personalSearch: pMock, kvarkClient: null, }); await cr.search('test', { limit: 5, profile: 'recent' }); expect(wsMock.search).toHaveBeenCalledWith('test', { limit: 5, profile: 'recent' }); expect(pMock.search).toHaveBeenCalledWith('test', { limit: 5, profile: 'recent' }); }); it('passes limit to KVARK client search', async () => { const searchFn = vi.fn(async () => KVARK_RESPONSE); const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock([]), personalSearch: makeSearchMock([]), kvarkClient: makeKvarkClient({ search: searchFn }), }); await cr.search('query', { limit: 7 }); expect(searchFn).toHaveBeenCalledWith('query', { limit: 7 }); }); it('uses sensible defaults (limit=10, profile=balanced, scope=all)', async () => { const wsMock = makeSearchMock([]); const pMock = makeSearchMock([]); const cr = new CombinedRetrieval({ workspaceSearch: wsMock, personalSearch: pMock, kvarkClient: null, }); await cr.search('test'); expect(wsMock.search).toHaveBeenCalledWith('test', { limit: 10, profile: 'balanced' }); expect(pMock.search).toHaveBeenCalledWith('test', { limit: 10, profile: 'balanced' }); }); it('handles KVARK returning zero results without error', async () => { const emptyResponse: KvarkSearchResponseLike = { results: [], total: 0, query: 'nothing' }; const searchFn = vi.fn(async () => emptyResponse); const cr = new CombinedRetrieval({ workspaceSearch: makeSearchMock([]), personalSearch: makeSearchMock([]), kvarkClient: makeKvarkClient({ search: searchFn }), }); const result = await cr.search('nothing'); expect(result.kvarkResults).toHaveLength(0); expect(result.kvarkAvailable).toBe(true); expect(result.kvarkSkipped).toBe(false); expect(result.kvarkError).toBeUndefined(); }); });