import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { KtxEmbeddingPort } from '../../../src/context/core/embedding.js'; import { SqliteContextEvidenceStore } from '../../../src/context/ingest/context-evidence/sqlite-context-evidence-store.js'; import { ContextCandidateMarkTool } from '../../../src/context/tools/context-candidate-mark.tool.js'; import { ContextCandidateWriteTool } from '../../../src/context/tools/context-candidate-write.tool.js'; import { ContextEvidenceNeighborsTool } from '../../../src/context/tools/context-evidence-neighbors.tool.js'; import { ContextEvidenceReadTool } from '../../../src/context/tools/context-evidence-read.tool.js'; import { ContextEvidenceSearchTool } from '../../../src/context/tools/context-evidence-search.tool.js'; import type { ContextEvidenceToolStorePort } from '../../../src/context/tools/context-evidence-tool-store.js'; import { createTouchedSlSources } from '../../../src/context/tools/touched-sl-sources.js'; import type { ToolContext } from '../../../src/context/tools/base-tool.js'; import type { ToolSession } from '../../../src/context/tools/tool-session.js'; const ingestContext = (): ToolContext => ({ sourceId: 'ingest', messageId: 'job-1-wu-unit-1', userId: 'system', connectionId: '00000000-0000-0000-0000-000000000001', ingest: { runId: '10000000-0000-0000-0000-000000000001', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion', }, session: { connectionId: '00000000-0000-0000-0000-000000000001', isWorktreeScoped: true, preHead: 'abc123', touchedSlSources: createTouchedSlSources(), actions: [], ingest: { runId: '10000000-0000-0000-0000-000000000001', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion', }, } as unknown as ToolSession, }); const makeEmbeddingService = (overrides: Partial = {}) => ({ computeEmbedding: vi.fn().mockResolvedValue([0.25, 0.5, 0.75]), ...overrides, }) as Partial as KtxEmbeddingPort; describe('context evidence tools', () => { it('searches context evidence with ingest defaults', async () => { const repository = { searchRRF: vi.fn().mockResolvedValue([ { chunkId: 'chunk-1', documentId: 'doc-1', externalId: 'page-1', title: 'Revenue Recognition', path: 'Company Handbook / Finance / Revenue Recognition', url: 'https://notion.example/page-1', snippet: 'Booked revenue excludes refunds and test accounts.', score: 0.35, citation: { source: 'notion', pageId: 'page-1', rawPath: 'pages/page-1/page.md' }, stableCitationKey: 'notion:page-1:policy:abc', syncId: 'sync-1', lastEditedAt: new Date('2026-04-12T10:15:00.000Z'), matchReasons: ['lexical', 'semantic'], lanes: [ { lane: 'lexical', status: 'available', requestedCandidatePoolLimit: 25, effectiveCandidatePoolLimit: 25, returnedCandidateCount: 1, weight: 1.5, }, { lane: 'semantic', status: 'available', requestedCandidatePoolLimit: 25, effectiveCandidatePoolLimit: 25, returnedCandidateCount: 1, weight: 2, }, ], }, ]), } as Partial as ContextEvidenceToolStorePort; const embeddings = { computeEmbedding: vi.fn().mockResolvedValue([0.1, ...Array.from({ length: 383 }, () => 0)]), } as Partial as KtxEmbeddingPort; const tool = new ContextEvidenceSearchTool(repository, embeddings); const result = await tool.call({ query: 'revenue refunds', limit: 5, includeDeleted: false }, ingestContext()); expect(repository.searchRRF).toHaveBeenCalledWith({ connectionId: '00000000-0000-0000-0000-000000000001', sourceKey: 'notion', queryEmbedding: [0.1, ...Array.from({ length: 383 }, () => 0)], queryText: 'revenue refunds', limit: 5, includeDeleted: false, currentRunId: '10000000-0000-0000-0000-000000000001', }); expect(result.markdown).toContain('Revenue Recognition'); expect(result.markdown).toContain('matchReasons: lexical, semantic'); expect(result.structured.success).toBe(true); if (result.structured.success) { expect(result.structured.results[0]).toMatchObject({ chunkId: 'chunk-1', stableCitationKey: 'notion:page-1:policy:abc', matchReasons: ['lexical', 'semantic'], lanes: expect.arrayContaining([expect.objectContaining({ lane: 'lexical', status: 'available' })]), }); } }); it('returns a structured ingest metadata error outside ingest sessions', async () => { const tool = new ContextEvidenceSearchTool( { searchRRF: vi.fn() } as Partial as ContextEvidenceToolStorePort, { computeEmbedding: vi.fn() } as Partial as KtxEmbeddingPort, ); const result = await tool.call( { query: 'revenue', limit: 5, includeDeleted: false }, { sourceId: 'research', messageId: 'm1', userId: 'user-1' }, ); expect(result.structured).toMatchObject({ success: false, error: 'INGEST_METADATA_REQUIRED' }); }); it('reads a full document by external id', async () => { const repository = { readDocumentByExternalId: vi.fn().mockResolvedValue({ document: { id: 'doc-1', title: 'Onboarding SOP', path: 'Ops / Onboarding SOP', external_id: 'page-ops', raw_path: 'pages/page-ops/page.md', url: 'https://notion.example/page-ops', }, chunks: [ { id: 'chunk-1', heading_path: ['Onboarding SOP', 'Checklist'], content: 'Create account, invite to workspace, confirm dashboard access.', citation: { source: 'notion', pageId: 'page-ops' }, }, ], }), } as Partial as ContextEvidenceToolStorePort; const tool = new ContextEvidenceReadTool(repository); const result = await tool.call({ externalId: 'page-ops', includeNeighborChunks: false }, ingestContext()); expect(repository.readDocumentByExternalId).toHaveBeenCalledWith( '00000000-0000-0000-0000-000000000001', 'notion', 'page-ops', '10000000-0000-0000-0000-000000000001', ); expect(result.markdown).toContain('## Onboarding SOP'); expect(result.markdown).toContain('Create account'); expect(result.structured.success).toBe(true); if (result.structured.success) { expect(result.structured.found).toBe(true); } }); it('reads documents and chunks by id with connection and source scope', async () => { const repository = { readDocumentById: vi.fn().mockResolvedValue({ document: { id: '00000000-0000-0000-0000-000000000201', title: 'Scoped Document', path: 'Scoped Document', external_id: 'page-scoped', url: null, }, chunks: [{ id: 'chunk-1', content: 'Scoped content.' }], }), readChunkById: vi.fn().mockResolvedValue({ document: { id: '00000000-0000-0000-0000-000000000201', title: 'Scoped Document', path: 'Scoped Document', external_id: 'page-scoped', url: null, }, chunk: { id: '00000000-0000-0000-0000-000000000301', content: 'Scoped chunk.', citation: { source: 'notion' }, }, }), } as Partial as ContextEvidenceToolStorePort; const tool = new ContextEvidenceReadTool(repository); await tool.call( { documentId: '00000000-0000-0000-0000-000000000201', includeNeighborChunks: false }, ingestContext(), ); await tool.call({ chunkId: '00000000-0000-0000-0000-000000000301', includeNeighborChunks: false }, ingestContext()); expect(repository.readDocumentById).toHaveBeenCalledWith( '00000000-0000-0000-0000-000000000201', '00000000-0000-0000-0000-000000000001', 'notion', '10000000-0000-0000-0000-000000000001', ); expect(repository.readChunkById).toHaveBeenCalledWith( '00000000-0000-0000-0000-000000000301', '00000000-0000-0000-0000-000000000001', 'notion', '10000000-0000-0000-0000-000000000001', ); }); it('lists evidence neighbors', async () => { const repository = { findNeighborDocuments: vi.fn().mockResolvedValue([ { documentId: 'doc-child', externalId: 'page-child', title: 'Revenue Caveats', path: 'Company Handbook / Finance / Revenue Caveats', relation: 'children', url: null, lastEditedAt: null, }, ]), } as Partial as ContextEvidenceToolStorePort; const tool = new ContextEvidenceNeighborsTool(repository); const result = await tool.call({ documentId: 'doc-1', relation: 'children', limit: 10 }, ingestContext()); expect(repository.findNeighborDocuments).toHaveBeenCalledWith({ connectionId: '00000000-0000-0000-0000-000000000001', sourceKey: 'notion', documentId: 'doc-1', relation: 'children', limit: 10, currentRunId: '10000000-0000-0000-0000-000000000001', }); expect(result.markdown).toContain('Revenue Caveats'); }); it('writes a cited candidate with durable evidence refs', async () => { const repository = { readChunksByIds: vi.fn().mockResolvedValue([ { chunkId: '00000000-0000-0000-0000-000000000101', documentId: 'doc-1', externalId: 'page-1', title: 'Revenue Recognition', path: 'Company Handbook / Finance / Revenue Recognition', url: 'https://notion.example/page-1', rawPath: 'pages/page-1/page.md', content: 'Booked revenue excludes refunds and test accounts.', citation: { source: 'notion', pageId: 'page-1', rawPath: 'pages/page-1/page.md' }, stableCitationKey: 'notion:page-1:policy:abc', syncId: 'sync-1', lastEditedAt: new Date('2026-04-12T10:15:00.000Z'), }, ]), insertCandidate: vi.fn().mockResolvedValue({ id: 'candidate-1', candidate_key: 'revenue-definition', promotion_score: 10, status: 'pending', }), } as Partial as ContextEvidenceToolStorePort; const embeddings = makeEmbeddingService(); const tool = new ContextCandidateWriteTool(repository, embeddings); const result = await tool.call( { candidateKey: 'revenue-definition', topic: 'Revenue Recognition', assertion: 'Booked revenue excludes refunds and test accounts.', rationale: 'Finance handbook is the source of truth and describes the reusable revenue rule.', evidenceChunkIds: ['00000000-0000-0000-0000-000000000101'], suggestedPageKey: 'revenue-definition', actionHint: 'create', durabilityScore: 3, authorityScore: 3, reuseScore: 3, noveltyScore: 2, riskScore: 1, }, ingestContext(), ); expect(repository.readChunksByIds).toHaveBeenCalledWith( ['00000000-0000-0000-0000-000000000101'], '00000000-0000-0000-0000-000000000001', 'notion', '10000000-0000-0000-0000-000000000001', ); expect(repository.insertCandidate).toHaveBeenCalledWith( expect.objectContaining({ runId: '10000000-0000-0000-0000-000000000001', connectionId: '00000000-0000-0000-0000-000000000001', sourceKey: 'notion', candidateKey: 'revenue-definition', promotionScore: 10, status: 'pending', evidenceRefs: [ expect.objectContaining({ chunkId: '00000000-0000-0000-0000-000000000101', stableCitationKey: 'notion:page-1:policy:abc', snippetHash: createHash('sha256') .update('Booked revenue excludes refunds and test accounts.') .digest('hex'), }), ], }), ); expect(embeddings.computeEmbedding).toHaveBeenCalledWith( 'Revenue Recognition - Booked revenue excludes refunds and test accounts.', ); expect(repository.insertCandidate).toHaveBeenCalledWith( expect.objectContaining({ embedding: [0.25, 0.5, 0.75], }), ); expect(result.structured).toMatchObject({ success: true, candidateKey: 'revenue-definition', promotionScore: 10 }); }); it('saves candidate writes with a null embedding when embedding generation fails', async () => { const repository = { readChunksByIds: vi.fn().mockResolvedValue([ { chunkId: '00000000-0000-0000-0000-000000000101', documentId: 'doc-1', externalId: 'page-1', title: 'Revenue Recognition', path: 'Company Handbook / Finance / Revenue Recognition', url: 'https://notion.example/page-1', rawPath: 'pages/page-1/page.md', content: 'Booked revenue excludes refunds and test accounts.', citation: { source: 'notion', pageId: 'page-1', rawPath: 'pages/page-1/page.md' }, stableCitationKey: 'notion:page-1:policy:abc', syncId: 'sync-1', lastEditedAt: new Date('2026-04-12T10:15:00.000Z'), }, ]), insertCandidate: vi.fn().mockResolvedValue({ id: 'candidate-1', candidate_key: 'revenue-definition', promotion_score: 10, status: 'pending', }), } as Partial as ContextEvidenceToolStorePort; const embeddings = makeEmbeddingService({ computeEmbedding: vi.fn().mockRejectedValue(new Error('embedding provider unavailable')), }); const tool = new ContextCandidateWriteTool(repository, embeddings); const result = await tool.call( { candidateKey: 'revenue-definition', topic: 'Revenue Recognition', assertion: 'Booked revenue excludes refunds and test accounts.', rationale: 'Finance handbook is the source of truth and describes the reusable revenue rule.', evidenceChunkIds: ['00000000-0000-0000-0000-000000000101'], suggestedPageKey: 'revenue-definition', actionHint: 'create', durabilityScore: 3, authorityScore: 3, reuseScore: 3, noveltyScore: 2, riskScore: 1, }, ingestContext(), ); expect(embeddings.computeEmbedding).toHaveBeenCalledWith( 'Revenue Recognition - Booked revenue excludes refunds and test accounts.', ); expect(repository.insertCandidate).toHaveBeenCalledWith( expect.objectContaining({ embedding: null, }), ); expect(result.structured).toMatchObject({ success: true, candidateKey: 'revenue-definition', promotionScore: 10 }); }); it('rejects candidate writes without evidence chunks', async () => { const embeddings = makeEmbeddingService(); const tool = new ContextCandidateWriteTool( { insertCandidate: vi.fn(), } as Partial as ContextEvidenceToolStorePort, embeddings, ); const result = await tool.call( { candidateKey: 'uncited', topic: 'Uncited', assertion: 'This has no evidence.', rationale: 'No evidence was provided.', evidenceChunkIds: [], actionHint: 'create', durabilityScore: 1, authorityScore: 1, reuseScore: 1, noveltyScore: 1, riskScore: 1, }, ingestContext(), ); expect(result.structured).toMatchObject({ success: false, error: 'EVIDENCE_REQUIRED' }); expect(embeddings.computeEmbedding).not.toHaveBeenCalled(); }); it('marks a candidate status during reconciliation', async () => { const repository = { updateCandidateStatus: vi.fn().mockResolvedValue({ id: 'candidate-1', candidate_key: 'revenue-definition', status: 'promoted', }), } as Partial as ContextEvidenceToolStorePort; const tool = new ContextCandidateMarkTool(repository); const result = await tool.call( { candidateKey: 'revenue-definition', status: 'promoted', rejectionReason: null }, ingestContext(), ); expect(repository.updateCandidateStatus).toHaveBeenCalledWith({ runId: '10000000-0000-0000-0000-000000000001', candidateKey: 'revenue-definition', status: 'promoted', rejectionReason: null, }); expect(result.structured).toMatchObject({ success: true, candidateKey: 'revenue-definition', status: 'promoted' }); }); }); describe('context evidence tools against real SqliteContextEvidenceStore', () => { let tempDir: string; let dbPath: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-context-tools-sqlite-')); dbPath = join(tempDir, '.ktx', 'db.sqlite'); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); const realStoreContext = (): ToolContext => ({ sourceId: 'ingest', messageId: 'job-1-wu-unit-1', userId: 'system', connectionId: 'conn-1', ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion', }, session: { connectionId: 'conn-1', isWorktreeScoped: true, preHead: 'abc123', touchedSlSources: createTouchedSlSources(), actions: [], ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion', }, } as unknown as ToolSession, }); async function seedChunk(store: SqliteContextEvidenceStore): Promise { const doc = await store.upsertDocument({ runId: 'run-1', connectionId: 'conn-1', sourceKey: 'notion', externalId: 'page-1', externalParentId: null, databaseId: null, dataSourceId: null, title: 'Revenue Recognition', path: 'Company Handbook / Finance / Revenue Recognition', url: 'https://notion.test/page-1', objectType: 'page', lastEditedAt: new Date('2026-04-30T10:00:00.000Z'), lastEditedBy: 'user-1', rawPath: 'pages/page-1/page.md', syncId: 'sync-1', contentHash: 'hash-page-1', publishState: 'published', metadata: {}, }); await store.replaceChunks(doc.id, [ { chunkKey: 'intro', headingPath: ['Revenue'], ordinal: 0, content: 'Booked revenue excludes refunds and test accounts.', searchText: 'booked revenue excludes refunds test accounts', embedding: [1, 0, 0], tokenCount: 8, citation: { source: 'notion', pageId: 'page-1', rawPath: 'pages/page-1/page.md' }, stableCitationKey: 'notion:page-1:intro', syncId: 'sync-1', contentHash: 'chunk-page-1', }, ]); const read = await store.readDocumentByExternalId('conn-1', 'notion', 'page-1', 'run-1'); if (!read) { throw new Error('seeded chunk not readable'); } return read.chunks[0].id; } it('candidate write accepts the prefixed chunkId returned by the real store and persists', async () => { const store = new SqliteContextEvidenceStore({ dbPath }); const chunkId = await seedChunk(store); expect(chunkId).toMatch(/^ctxchunk-[0-9a-f-]{36}$/); const tool = new ContextCandidateWriteTool(store, { computeEmbedding: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]), } as Partial as KtxEmbeddingPort); const parsed = tool.parseInput({ candidateKey: 'revenue-definition', topic: 'Revenue Recognition', assertion: 'Booked revenue excludes refunds and test accounts.', rationale: 'The Finance handbook is the source of truth.', evidenceChunkIds: [chunkId], actionHint: 'create', durabilityScore: 3, authorityScore: 3, reuseScore: 3, noveltyScore: 2, riskScore: 1, }); const result = await tool.call(parsed, realStoreContext()); expect(result.structured).toMatchObject({ success: true, candidateKey: 'revenue-definition', promotionScore: 10, status: 'pending', }); }); it('candidate write schema rejects a bare UUID without the ctxchunk- prefix', () => { const tool = new ContextCandidateWriteTool( {} as ContextEvidenceToolStorePort, { computeEmbedding: vi.fn() } as Partial as KtxEmbeddingPort, ); expect(() => tool.parseInput({ candidateKey: 'revenue-definition', topic: 'Revenue Recognition', assertion: 'Booked revenue excludes refunds and test accounts.', rationale: 'Finance handbook is the source of truth.', evidenceChunkIds: ['00000000-0000-0000-0000-000000000101'], actionHint: 'create', durabilityScore: 3, authorityScore: 3, reuseScore: 3, noveltyScore: 2, riskScore: 1, }), ).toThrow(/ctxchunk/); }); it('evidence read schema rejects bare UUIDs for chunkId and documentId', () => { const tool = new ContextEvidenceReadTool({} as ContextEvidenceToolStorePort); expect(() => tool.parseInput({ chunkId: '00000000-0000-0000-0000-000000000301', includeNeighborChunks: false }), ).toThrow(/ctxchunk/); expect(() => tool.parseInput({ documentId: '00000000-0000-0000-0000-000000000201', includeNeighborChunks: false }), ).toThrow(/ctxdoc/); }); it('evidence neighbors schema rejects bare UUIDs for documentId', () => { const tool = new ContextEvidenceNeighborsTool({} as ContextEvidenceToolStorePort); expect(() => tool.parseInput({ documentId: '00000000-0000-0000-0000-000000000201', relation: 'children', limit: 10 }), ).toThrow(/ctxdoc/); }); });