diff --git a/packages/cli/src/mcp-server-factory.test.ts b/packages/cli/src/mcp-server-factory.test.ts new file mode 100644 index 00000000..779e008a --- /dev/null +++ b/packages/cli/src/mcp-server-factory.test.ts @@ -0,0 +1,193 @@ +import { createDefaultKtxMcpServer, createLocalProjectMcpContextPorts } from '@ktx/context/mcp'; +import { createLocalProjectMemoryIngest } from '@ktx/context/memory'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveProjectEmbeddingProvider } from './embedding-resolution.js'; +import { createKtxCliScanConnector } from './local-scan-connectors.js'; +import { createKtxMcpServerFactory } from './mcp-server-factory.js'; + +type FakeEmbeddingProvider = { + maxBatchSize: number; + embed(text: string): Promise; + embedMany(texts: string[]): Promise; +}; + +const mocks = vi.hoisted(() => ({ + queryExecutor: { execute: vi.fn() }, + semanticLayerCompute: { validateSources: vi.fn(), generateSources: vi.fn(), query: vi.fn() }, + sqlAnalysis: { analyzeForFingerprint: vi.fn(), analyzeBatch: vi.fn(), validateReadOnly: vi.fn() }, + memoryIngest: { ingest: vi.fn(), status: vi.fn(), waitForRun: vi.fn() }, +})); + +vi.mock('@ktx/context', () => ({ + KtxIngestEmbeddingPortAdapter: class { + readonly maxBatchSize: number; + + constructor(private readonly provider: FakeEmbeddingProvider) { + this.maxBatchSize = provider.maxBatchSize; + } + + computeEmbedding(text: string): Promise { + return this.provider.embed(text); + } + + computeEmbeddingsBulk(texts: string[]): Promise { + return this.provider.embedMany(texts); + } + }, +})); + +vi.mock('@ktx/context/mcp', () => ({ + createDefaultKtxMcpServer: vi.fn(() => ({ kind: 'mcp-server' })), + createLocalProjectMcpContextPorts: vi.fn(() => ({ context_tool: { name: 'context_tool' } })), +})); + +vi.mock('@ktx/context/memory', () => ({ + createLocalProjectMemoryIngest: vi.fn(() => mocks.memoryIngest), +})); + +vi.mock('./embedding-resolution.js', () => ({ + resolveProjectEmbeddingProvider: vi.fn(), +})); + +vi.mock('./ingest-query-executor.js', () => ({ + createKtxCliIngestQueryExecutor: vi.fn(() => mocks.queryExecutor), +})); + +vi.mock('./local-scan-connectors.js', () => ({ + createKtxCliScanConnector: vi.fn(() => ({ source: 'fake-scan-connector' })), +})); + +vi.mock('./managed-python-command.js', () => ({ + createManagedPythonSemanticLayerComputePort: vi.fn(async () => mocks.semanticLayerCompute), +})); + +vi.mock('./managed-python-http.js', () => ({ + createManagedDaemonSqlAnalysisPort: vi.fn(() => mocks.sqlAnalysis), +})); + +const project = { + projectDir: '/work/project', + configPath: '/work/project/ktx.yaml', + config: {}, + coreConfig: {}, + git: {}, + fileStore: {}, +}; + +const io = { + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() }, +}; + +describe('createKtxMcpServerFactory', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes a resolved embedding provider to MCP context ports and memory ingest', async () => { + const provider = { + maxBatchSize: 4, + embed: vi.fn(async () => [0.2, 0.4]), + embedMany: vi.fn(async () => [[0.2, 0.4]]), + }; + vi.mocked(resolveProjectEmbeddingProvider).mockResolvedValue({ kind: 'configured', provider } as never); + + const factory = await createKtxMcpServerFactory({ + project: project as never, + projectDir: project.projectDir, + cliVersion: '0.5.0', + io, + }); + + const contextOptions = vi.mocked(createLocalProjectMcpContextPorts).mock.calls[0][1] as { + embeddingService: { + computeEmbedding(text: string): Promise; + computeEmbeddingsBulk(texts: string[]): Promise; + }; + queryExecutor: unknown; + semanticLayerCompute: unknown; + sqlAnalysis: unknown; + localScan: { + createConnector(connectionId: string): Promise; + }; + }; + await expect(contextOptions.embeddingService.computeEmbedding('gross revenue')).resolves.toEqual([0.2, 0.4]); + await expect(contextOptions.embeddingService.computeEmbeddingsBulk(['gross revenue'])).resolves.toEqual([[0.2, 0.4]]); + await expect(contextOptions.localScan.createConnector('warehouse')).resolves.toEqual({ + source: 'fake-scan-connector', + }); + + expect(provider.embed).toHaveBeenCalledWith('gross revenue'); + expect(provider.embedMany).toHaveBeenCalledWith(['gross revenue']); + expect(createKtxCliScanConnector).toHaveBeenCalledWith(project, 'warehouse'); + expect(contextOptions).toMatchObject({ + queryExecutor: mocks.queryExecutor, + semanticLayerCompute: mocks.semanticLayerCompute, + sqlAnalysis: mocks.sqlAnalysis, + }); + expect(createLocalProjectMemoryIngest).toHaveBeenCalledWith( + project, + expect.objectContaining({ + embeddingProvider: provider, + queryExecutor: mocks.queryExecutor, + semanticLayerCompute: mocks.semanticLayerCompute, + }), + ); + + expect(factory()).toEqual({ kind: 'mcp-server' }); + expect(createDefaultKtxMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + contextTools: expect.objectContaining({ + context_tool: { name: 'context_tool' }, + memoryIngest: mocks.memoryIngest, + }), + }), + ); + }); + + it('uses null embedding ports when no configured provider is available', async () => { + vi.mocked(resolveProjectEmbeddingProvider).mockResolvedValue({ kind: 'managed-unavailable' } as never); + + await createKtxMcpServerFactory({ + project: project as never, + projectDir: project.projectDir, + cliVersion: '0.5.0', + io, + }); + + expect(vi.mocked(createLocalProjectMcpContextPorts).mock.calls[0][1]).toMatchObject({ + embeddingService: null, + }); + expect(createLocalProjectMemoryIngest).toHaveBeenCalledWith( + project, + expect.objectContaining({ + embeddingProvider: null, + }), + ); + }); + + it('omits memory ingest and logs when memory ingest construction fails', async () => { + vi.mocked(resolveProjectEmbeddingProvider).mockResolvedValue({ kind: 'disabled' } as never); + vi.mocked(createLocalProjectMemoryIngest).mockImplementationOnce(() => { + throw new Error('missing local memory prerequisites'); + }); + + const factory = await createKtxMcpServerFactory({ + project: project as never, + projectDir: project.projectDir, + cliVersion: '0.5.0', + io, + }); + + factory(); + + expect(io.stderr.write).toHaveBeenCalledWith( + 'KTX MCP memory_ingest disabled: missing local memory prerequisites\n', + ); + expect(createDefaultKtxMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + contextTools: { context_tool: { name: 'context_tool' } }, + }), + ); + }); +}); diff --git a/packages/context/src/ingest/local-bundle-runtime.test.ts b/packages/context/src/ingest/local-bundle-runtime.test.ts index 89dd2ce5..df906adf 100644 --- a/packages/context/src/ingest/local-bundle-runtime.test.ts +++ b/packages/context/src/ingest/local-bundle-runtime.test.ts @@ -111,6 +111,26 @@ describe('createLocalBundleIngestRuntime', () => { ); }); + it('warns when embeddings are configured but no embedding provider is supplied', () => { + const logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + project.config.ingest.embeddings = { + backend: 'openai', + model: 'text-embedding-3-small', + dimensions: 1536, + }; + + createLocalBundleIngestRuntime({ + project, + adapters: [new FakeSourceAdapter()], + agentRunner: testAgentRunner(), + logger: logger as never, + }); + + expect(logger.warn).toHaveBeenCalledWith( + '[local-bundle-runtime] embeddings backend "openai" is configured but no embedding provider was passed; embedding-dependent stages will run against a no-op embedding port.', + ); + }); + it('builds runner deps with local SQLite stores and context tools enabled', async () => { const agentRunner = testAgentRunner(); diff --git a/packages/context/src/memory/local-memory.test.ts b/packages/context/src/memory/local-memory.test.ts index 24e0df14..04aabce1 100644 --- a/packages/context/src/memory/local-memory.test.ts +++ b/packages/context/src/memory/local-memory.test.ts @@ -88,6 +88,25 @@ describe('createLocalProjectMemoryIngest', () => { await rm(tempDir, { recursive: true, force: true }); }); + it('warns when embeddings are configured but memory ingest is created without an embedding provider', async () => { + const project = await initKtxProject({ projectDir: tempDir }); + project.config.ingest.embeddings = { + backend: 'openai', + model: 'text-embedding-3-small', + dimensions: 1536, + }; + const logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + createLocalProjectMemoryIngest(project, { + agentRunner: { runLoop: vi.fn() } as never, + logger: logger as never, + }); + + expect(logger.warn).toHaveBeenCalledWith( + '[memory-ingest] embeddings backend "openai" is configured but no embedding provider was passed; semantic search will fall back to a no-op embedding port.', + ); + }); + it('captures a wiki page through the local memory agent and persists pollable status', async () => { const project = await initKtxProject({ projectDir: tempDir }); const agentRunner = { diff --git a/packages/context/src/project/config.test.ts b/packages/context/src/project/config.test.ts index f8faad58..3b7f2feb 100644 --- a/packages/context/src/project/config.test.ts +++ b/packages/context/src/project/config.test.ts @@ -176,6 +176,28 @@ llm: }); }); + it('requires a non-empty Vertex location when the Vertex provider block is present', () => { + const yaml = ` +llm: + provider: + backend: vertex + vertex: + project: local-gcp-project +`; + + expect(() => parseKtxProjectConfig(yaml)).toThrow(/llm\.provider\.vertex\.location/); + + const validation = validateKtxProjectConfig(yaml); + expect(validation.ok).toBe(false); + expect(validation.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'llm.provider.vertex.location', + }), + ]), + ); + }); + it('parses Claude Code as a first-class LLM backend', () => { const config = parseKtxProjectConfig(` llm: diff --git a/packages/context/src/sl/semantic-layer.service.test.ts b/packages/context/src/sl/semantic-layer.service.test.ts index 6ac3460a..0844a3c5 100644 --- a/packages/context/src/sl/semantic-layer.service.test.ts +++ b/packages/context/src/sl/semantic-layer.service.test.ts @@ -67,6 +67,23 @@ describe('listConnectionIdsWithNames', () => { }); }); +describe('loadSource', () => { + it('warns and returns null when an existing source file has invalid YAML', async () => { + const logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const configService = { + readFile: vi.fn().mockResolvedValue({ content: 'name: [' }), + }; + const service = new SemanticLayerService(configService as never, connectionCatalog(), pythonPort, logger as never); + + await expect(service.loadSource('warehouse', 'orders')).resolves.toBeNull(); + + expect(configService.readFile).toHaveBeenCalledWith('semantic-layer/warehouse/orders.yaml'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[loadSource] warehouse/orders.yaml: YAML parse failed:'), + ); + }); +}); + describe('composeOverlay', () => { it('carries top-level segments from overlay into the composed source', () => { const overlay = { @@ -856,6 +873,22 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', ( expect(loadErrors.join('\n')).toContain(overlayPath); expect(loadErrors.join('\n')).toContain("move it to 'column_overrides:'"); }); + + it('reports and logs directory listing failures instead of treating them as empty sources', async () => { + const logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + configService.listFiles.mockRejectedValue(new Error('permission denied')); + service = new SemanticLayerService(configService as never, connectionCatalog(), pythonPort, logger as never); + + const { sources, loadErrors } = await service.loadAllSources('conn-1'); + + expect(sources).toEqual([]); + expect(loadErrors).toEqual([ + 'Failed to list semantic-layer files under semantic-layer/conn-1: permission denied', + ]); + expect(logger.warn).toHaveBeenCalledWith( + 'Failed to list semantic-layer files under semantic-layer/conn-1: permission denied', + ); + }); }); describe('validateWithProposedSource', () => { diff --git a/packages/context/src/wiki/knowledge-wiki.service.test.ts b/packages/context/src/wiki/knowledge-wiki.service.test.ts index d9242f97..88bd92ab 100644 --- a/packages/context/src/wiki/knowledge-wiki.service.test.ts +++ b/packages/context/src/wiki/knowledge-wiki.service.test.ts @@ -50,6 +50,27 @@ function makeService() { const fm: WikiFrontmatter = { summary: 'sum', usage_mode: 'auto' }; +describe('KnowledgeWikiService file reads', () => { + it('warns and returns null when an existing page cannot be parsed', async () => { + const { service, configService, logger } = makeService(); + configService.readFile.mockResolvedValue({ content: '---\nsummary: [\n---\nBody' }); + + await expect(service.readPage('GLOBAL', null, 'revenue')).resolves.toBeNull(); + + expect(configService.readFile).toHaveBeenCalledWith('wiki/global/revenue.md'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('[readPage] wiki/global/revenue.md: parse failed:')); + }); + + it('warns and returns an empty page list when directory listing fails', async () => { + const { service, configService, logger } = makeService(); + configService.listFiles.mockRejectedValue(new Error('filesystem unavailable')); + + await expect(service.listPageKeys('GLOBAL', null)).resolves.toEqual([]); + + expect(logger.warn).toHaveBeenCalledWith('[listPageKeys] wiki/global: filesystem unavailable'); + }); +}); + describe('KnowledgeWikiService.syncIndex result stats', () => { it('reports scanned, updated, deleted, and embedding counts', async () => { const { service, pagesRepository, embeddingService, configService } = makeService();