test: split cli tests from source tree (#216)

* feat(cli): define full warehouse dialect contract

* test(cli): keep dialect edge tests focused

* fix(cli): stabilize dialect contract foundation

* refactor(connectors): own read-only query preparation

* refactor(connectors): resolve dialects through registry

* refactor(connectors): keep concrete dialect classes internal

* chore(workspace): enforce dialect import boundary

* refactor(cli): resolve relationship dialect at scan boundary

* refactor(cli): use dialect display parsing for entity details

* refactor(cli): use dialect display parsing for warehouse catalog

* refactor(cli): use dialect SQL in relationship workflows

* test(cli): verify solid dialect scan workflow closure

* test: split cli tests from source tree

* refactor(cli): standardize BigQuery scope listing

* feat(sqlite): implement connector scope listing

* test(connectors): cover required table listing

* feat(cli): add warehouse driver registry

* refactor(setup): route scope discovery through driver registry

* refactor(cli): route local query execution through driver registry

* refactor(historic-sql): route dialect support through driver registry

* refactor(cli): test warehouse connections through driver registry

* fix(cli): close driver registry type export gaps

* Improve setup daemon diagnostics

* refactor(setup): centralize rail-prefixed diagnostics + query-history fallback

Extract errorMessage, writePrefixedLines, and flushPrefixedBufferedCommandOutput
into clack.ts so the setup wizard, managed daemons, and embedding/agent steps
share one rail-formatted writer. setup-databases.ts also adds a
"disable query history and retry" option when the schema-context build fails
and query history is the likely culprit, surfaced via a new
failed-query-history-unavailable status.

* fix(cli): carry catalog through the picker so BigQuery/Snowflake/SQL Server scope filters match

The setup picker's KtxTableListEntry was a 2-level { schema, name }, so
qualifiedTableId always wrote db.name into enabled_tables. When BigQuery,
Snowflake, or SQL Server later ran fast ingest, their introspect step filtered
the scope set with scopedTableNames(scope, { catalog: projectId|database, db })
— catalog was non-null on the introspect side but null in the scope refs, so
every entry was rejected, the live-database adapter staged zero table files,
and detect() failed with 'Adapter "live-database" did not recognize fetched
source output'.

Align the picker boundary with the canonical 3-level KtxTableRef:

- Add catalog: string | null to KtxTableListEntry.
- BigQuery/Snowflake/SQL Server listTables populate catalog from the
  resolved projectId / database; Postgres/MySQL/ClickHouse/SQLite set null.
- qualifiedTableId emits catalog.schema.name when catalog is non-null
  (resolveEnabledTables already accepts the 3-part shape) and
  schemasFromEnabledTables now goes through parseDottedTableEntry so it
  recovers the schema correctly from both 2-part and 3-part entries.
- Export parseDottedTableEntry from enabled-tables.ts (@internal) for picker
  reuse.

Update listTables expectations in all seven connector tests and the setup /
picker test fixtures. Add a picker regression test that covers the
catalog-bearing round-trip (save + refine).

* fix(cli): allow debug telemetry under opt-out env
This commit is contained in:
Andrey Avtomonov 2026-05-26 08:49:05 +02:00 committed by GitHub
parent 924868841d
commit 56985b7e09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
548 changed files with 5048 additions and 2228 deletions

View file

@ -1,223 +0,0 @@
import { access, mkdtemp, readFile, 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 { initKtxProject } from '../../context/project/project.js';
import { createLocalProjectMemoryIngest } from './local-memory.js';
import { LocalMemoryRunStore } from './local-memory-runs.js';
vi.mock('ai', () => ({
generateText: vi.fn().mockResolvedValue({ text: '', toolCalls: [] }),
stepCountIs: (stepBudget: number) => stepBudget,
tool: (definition: unknown) => definition,
}));
async function expectPathMissing(path: string): Promise<void> {
await expect(access(path)).rejects.toThrow();
}
describe('LocalMemoryRunStore', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-memory-runs-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('persists running, done, and reloadable memory run status in SQLite', async () => {
const store = new LocalMemoryRunStore({
projectDir: tempDir,
idFactory: () => 'memory-run-1',
});
const created = await store.createRunning({ inputHash: 'hash-1', chatId: 'chat-1' });
expect(created).toEqual({ id: 'memory-run-1' });
await store.markRunning('memory-run-1', 'capturing');
await store.markDone('memory-run-1', {
signalDetected: true,
actions: [{ target: 'wiki', type: 'created', key: 'revenue', detail: 'Revenue definition' }],
skillsLoaded: ['wiki_capture'],
commitHash: 'abc123',
});
await expect(access(join(tempDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
await expectPathMissing(join(tempDir, '.ktx/memory-runs/memory-run-1.json'));
await expect(store.findById('memory-run-1')).resolves.toMatchObject({
id: 'memory-run-1',
status: 'done',
stage: 'done',
inputHash: 'hash-1',
chatId: 'chat-1',
error: null,
outputSummary: {
signalDetected: true,
commitHash: 'abc123',
},
});
const reloaded = new LocalMemoryRunStore({ projectDir: tempDir });
await expect(reloaded.findById('memory-run-1')).resolves.toMatchObject({
id: 'memory-run-1',
status: 'done',
stage: 'done',
inputHash: 'hash-1',
chatId: 'chat-1',
outputSummary: {
actions: [{ target: 'wiki', type: 'created', key: 'revenue', detail: 'Revenue definition' }],
skillsLoaded: ['wiki_capture'],
signalDetected: true,
commitHash: 'abc123',
},
});
});
});
describe('createLocalProjectMemoryIngest', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-local-memory-'));
});
afterEach(async () => {
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 = {
runLoop: async ({
toolSet,
}: {
toolSet: Record<string, { execute: (input: unknown, options?: { toolCallId?: string }) => Promise<unknown> }>;
}) => {
await toolSet.load_skill.execute({ name: 'wiki_capture' });
await toolSet.wiki_write.execute(
{
key: 'revenue',
summary: 'Revenue definition',
content: 'Revenue means paid order value net of refunds.',
tags: ['finance'],
},
{ toolCallId: 'wiki-write' },
);
return { stopReason: 'natural' as const };
},
};
const ingest = createLocalProjectMemoryIngest(project, {
agentRunner: agentRunner as never,
runIdFactory: () => 'memory-run-1',
});
await expect(
ingest.ingest({
userId: 'local-user',
chatId: 'chat-1',
userMessage: 'define revenue as paid order value net of refunds',
assistantMessage: 'Captured.',
sourceType: 'external_ingest',
}),
).resolves.toEqual({ runId: 'memory-run-1' });
await ingest.waitForRun('memory-run-1');
await expect(access(join(project.projectDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
await expectPathMissing(join(project.projectDir, '.ktx/memory-runs/memory-run-1.json'));
await expect(ingest.status('memory-run-1')).resolves.toMatchObject({
runId: 'memory-run-1',
status: 'done',
done: true,
captured: { wiki: ['revenue'], sl: [], xrefs: [] },
skillsLoaded: ['wiki_capture'],
signalDetected: true,
});
await expect(readFile(join(project.projectDir, 'wiki/global/revenue.md'), 'utf-8')).resolves.toContain(
'Revenue means paid order value net of refunds.',
);
});
it('captures a semantic-layer source for a named local connection id', async () => {
const project = await initKtxProject({ projectDir: tempDir });
project.config.connections.warehouse = { driver: 'postgres' };
const agentRunner = {
runLoop: async ({
toolSet,
}: {
toolSet: Record<string, { execute: (input: unknown, options?: { toolCallId?: string }) => Promise<unknown> }>;
}) => {
await toolSet.load_skill.execute({ name: 'sl' });
await toolSet.sl_write_source.execute(
{
connectionId: 'warehouse',
sourceName: 'orders',
source: {
name: 'orders',
table: 'public.orders',
grain: ['id'],
columns: [{ name: 'id', type: 'number' }],
joins: [],
measures: [{ name: 'order_count', expr: 'count(*)' }],
},
},
{ toolCallId: 'sl-write' },
);
return { stopReason: 'natural' as const };
},
};
const ingest = createLocalProjectMemoryIngest(project, {
agentRunner: agentRunner as never,
runIdFactory: () => 'memory-run-2',
});
await ingest.ingest({
userId: 'local-user',
chatId: 'chat-2',
userMessage: 'going forward define orders count as count of public orders',
assistantMessage: 'Captured.',
connectionId: 'warehouse',
sourceType: 'external_ingest',
});
await ingest.waitForRun('memory-run-2');
await expect(access(join(project.projectDir, '.ktx/db.sqlite'))).resolves.toBeUndefined();
await expectPathMissing(join(project.projectDir, '.ktx/memory-runs/memory-run-2.json'));
await expect(ingest.status('memory-run-2')).resolves.toMatchObject({
runId: 'memory-run-2',
status: 'done',
captured: { wiki: [], sl: ['orders'], xrefs: [] },
skillsLoaded: ['sl'],
signalDetected: true,
});
await expect(readFile(join(project.projectDir, 'semantic-layer/warehouse/orders.yaml'), 'utf-8')).resolves.toContain(
'order_count',
);
});
});

View file

@ -1,433 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Module-level mock for 'ai' so generateText is a stub. This file is separate from
// memory-agent.service.spec.ts so the existing pure-helper tests don't load the mock.
vi.mock('ai', () => ({
generateText: vi.fn().mockResolvedValue({ text: '', toolCalls: [] }),
stepCountIs: (n: number) => n,
tool: (def: unknown) => def,
}));
// Imported AFTER vi.mock so the mocked module is used.
import { generateText } from 'ai';
import { SYSTEM_GIT_AUTHOR } from '../../context/tools/authors.js';
import { MemoryAgentService } from './memory-agent.service.js';
interface BuiltMocks {
appSettings: any;
prompt: any;
eventTracker: any;
telemetry: any;
skillsRegistry: any;
wikiService: any;
indexRepository: any;
knowledgeSlRefsRepository: any;
knowledgeRepository: any;
embeddingService: any;
semanticLayerService: any;
slSearchService: any;
dataSourcesService: any;
configService: any;
gitService: any;
lockingService: any;
slSourcesRepository: any;
sessionWorktreeService: any;
semanticLayerSourceReconciler: any;
agentRunner: any;
slValidator: any;
toolsetFactory: any;
logger: any;
}
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
const scopedConfig = { writeFile: vi.fn(), deleteFile: vi.fn() };
const scopedGit = { revParseHead: vi.fn().mockResolvedValue('basesha') };
const sessionWorktree = {
chatId: 'chat-1',
workdir: '/tmp/wt/session-chat-1',
branch: 'session/chat-1',
baseSha: 'basesha',
createdAt: new Date(),
git: scopedGit,
config: scopedConfig,
};
const defaults: BuiltMocks = {
appSettings: {
settings: {
ai: {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
},
llm: { memoryIngestionModel: 'test-model' },
},
},
prompt: { loadPrompt: vi.fn().mockResolvedValue('base framing') },
eventTracker: { trackEvent: vi.fn(), createTelemetryIntegration: vi.fn().mockReturnValue(undefined) },
telemetry: {
isEnabled: () => false,
appSettingsService: { settings: { telemetry: { recordInputs: false, recordOutputs: false } } },
systemConfigService: { config: { instance: { name: 'test-instance' } } },
},
skillsRegistry: {
listSkills: vi.fn().mockResolvedValue([]),
buildSkillsPrompt: vi.fn().mockReturnValue(''),
getSkill: vi.fn(),
stripFrontmatter: vi.fn(),
},
wikiService: {
forWorktree: vi.fn().mockReturnThis(),
readPage: vi.fn(),
syncSinglePage: vi.fn(),
deleteFromIndex: vi.fn(),
},
indexRepository: { listPagesForUser: vi.fn().mockResolvedValue([]) },
knowledgeSlRefsRepository: { syncFromWiki: vi.fn().mockResolvedValue({ inserted: 0, deleted: 0 }) },
knowledgeRepository: {},
embeddingService: { computeEmbedding: vi.fn() },
semanticLayerService: {
forWorktree: vi.fn().mockReturnThis(),
loadAllSources: vi.fn().mockResolvedValue({ sources: [], loadErrors: [] }),
readSourceFile: vi.fn(),
},
slSearchService: { indexSources: vi.fn(), buildSearchText: vi.fn() },
dataSourcesService: {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById: vi.fn().mockResolvedValue({
id: 'conn-1',
name: 'Warehouse',
connectionType: 'POSTGRESQL',
}),
executeQuery: vi.fn(),
},
configService: {
enqueueCommitMessageJobForExternalCommit: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn(),
deleteFile: vi.fn(),
},
gitService: {
revParseHead: vi.fn().mockResolvedValue('basesha'),
squashMergeIntoMain: vi.fn().mockResolvedValue({ ok: true, squashSha: 'cafebabe', touchedPaths: ['a.yaml'] }),
},
lockingService: {
withLock: vi.fn().mockImplementation((_key: string, fn: () => Promise<unknown>) => fn()),
},
slSourcesRepository: { deleteByConnectionAndName: vi.fn() },
sessionWorktreeService: {
create: vi.fn().mockResolvedValue(sessionWorktree),
cleanup: vi.fn().mockResolvedValue(undefined),
},
semanticLayerSourceReconciler: { upsertRow: vi.fn() },
agentRunner: { runLoop: vi.fn().mockResolvedValue({ stopReason: 'natural' }) },
slValidator: { validateSingleSource: vi.fn().mockResolvedValue({ errors: [], warnings: [] }) },
toolsetFactory: {
createIngestWuToolset: vi.fn().mockReturnValue({
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
}),
createToolset: vi.fn().mockReturnValue({
toRuntimeTools: vi.fn().mockReturnValue({}),
getAllTools: vi.fn().mockReturnValue([]),
}),
},
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
};
return { ...defaults, ...overrides };
};
const buildService = (mocks: BuiltMocks): MemoryAgentService =>
new MemoryAgentService({
settings: {
knowledge: {
userScopedKnowledgeEnabled: mocks.appSettings.settings.ai.knowledge.userScopedKnowledgeEnabled,
},
slValidation: {
probeRowCount: mocks.appSettings.settings.ai.slValidation.probeRowCount,
},
llm: {
memoryIngestionModel: mocks.appSettings.settings.llm.memoryIngestionModel,
},
},
promptService: mocks.prompt,
skillsRegistry: mocks.skillsRegistry,
wikiService: mocks.wikiService,
knowledgeIndex: mocks.indexRepository,
knowledgeSlRefs: mocks.knowledgeSlRefsRepository,
semanticLayerService: mocks.semanticLayerService,
slSearchService: mocks.slSearchService,
connections: {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById:
mocks.dataSourcesService.getConnectionById ??
vi.fn().mockResolvedValue({
id: 'conn-1',
name: 'Warehouse',
connectionType: 'POSTGRESQL',
}),
executeQuery: mocks.dataSourcesService.executeQuery,
},
rootFileStore: mocks.configService,
gitService: mocks.gitService,
lockingService: mocks.lockingService,
slSourcesRepository: mocks.slSourcesRepository,
sessionWorktreeService: mocks.sessionWorktreeService,
semanticLayerSourceReconciler: mocks.semanticLayerSourceReconciler,
agentRunner: mocks.agentRunner,
slValidator: mocks.slValidator,
toolsetFactory: mocks.toolsetFactory,
telemetry: {
trackMemoryIngestion: mocks.eventTracker.trackEvent,
},
logger: mocks.logger,
});
const baseInput = {
userId: 'u1',
chatId: 'chat-1',
// Long enough + with a definition keyword so the prefilter doesn't skip.
userMessage: 'going forward exclude cancelled orders from revenue, this is the canonical definition',
};
const generateTextMock = vi.mocked(generateText);
beforeEach(() => {
generateTextMock.mockReset();
generateTextMock.mockResolvedValue({ text: '', toolCalls: [] } as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('MemoryAgentService.ingest — session-branch orchestration', () => {
it('happy path: creates worktree, runs LLM loop, squash-merges, enqueues note, cleans up', async () => {
const mocks = buildMocks();
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
// Phase 1: session worktree was created from main's HEAD.
expect(mocks.sessionWorktreeService.create).toHaveBeenCalledWith('chat-1', 'basesha');
// Phase 2: LLM loop ran with the assembled tools/system/prompt.
expect(mocks.agentRunner.runLoop).toHaveBeenCalledOnce();
// Phase 3: squash-merged onto main.
expect(mocks.gitService.squashMergeIntoMain).toHaveBeenCalledWith(
'session/chat-1',
SYSTEM_GIT_AUTHOR.name,
SYSTEM_GIT_AUTHOR.email,
expect.stringContaining('[chat=chat-1]'),
);
// Note enqueue happened on the ROOT configService, not the scoped one. The single
// touched path is passed as the diff scope.
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).toHaveBeenCalledWith(
{ commitHash: 'cafebabe' },
expect.stringContaining('[chat=chat-1]'),
'a.yaml',
);
// Cleanup ran with success.
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(
expect.objectContaining({ chatId: 'chat-1' }),
'success',
expect.any(Object),
);
expect(result.commitHash).toBe('cafebabe');
});
it('normalizes load_skill output to markdown while preserving structured payload', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-'));
const skillDir = join(tempDir, 'memory_agent');
await mkdir(skillDir, { recursive: true });
await writeFile(join(skillDir, 'SKILL.md'), '---\nname: memory_agent\n---\nSkill body', 'utf-8');
try {
const agentRunner = {
runLoop: vi.fn(async (params: any) => {
const result = await params.toolSet.load_skill.execute({ name: 'memory_agent' });
expect(result.markdown).toContain('memory_agent');
expect(result.structured).toMatchObject({ name: 'memory_agent' });
return { stopReason: 'natural' as const };
}),
};
const mocks = buildMocks({
agentRunner,
skillsRegistry: {
listSkills: vi.fn().mockResolvedValue([{ name: 'memory_agent', path: skillDir }]),
buildSkillsPrompt: vi.fn().mockReturnValue(''),
getSkill: vi.fn().mockResolvedValue({ name: 'memory_agent', path: skillDir }),
stripFrontmatter: vi.fn().mockReturnValue('Skill body'),
},
});
const svc = buildService(mocks);
await svc.ingest(baseInput);
expect(agentRunner.runLoop).toHaveBeenCalled();
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => {
const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
const mocks = buildMocks();
const svc = buildService(mocks);
try {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1';
await svc.ingest(baseInput);
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system='));
expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user='));
} finally {
if (previousDebugPrompts === undefined) {
delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
} else {
process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts;
}
}
});
it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
ok: true,
squashSha: 'basesha',
touchedPaths: [],
});
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'empty', expect.any(Object));
expect(result.commitHash).toBeNull();
});
it('conflict path: rolls back DB, cleanup(conflict, conflictPaths), returns commitHash=null with empty actions', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
ok: false,
conflict: true,
conflictPaths: ['semantic-layer/conn-x/fct_intakes.yaml'],
});
// Have the wikiService report a still-existing page in main, so rollback re-syncs.
mocks.wikiService.readPage.mockResolvedValue({
pageKey: 'phantom',
frontmatter: { summary: 'x', usage_mode: 'auto' },
content: 'body',
});
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.gitService.squashMergeIntoMain).toHaveBeenCalled();
// Cleanup got the conflict outcome + the paths.
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'conflict', {
conflictPaths: ['semantic-layer/conn-x/fct_intakes.yaml'],
});
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
expect(result.commitHash).toBeNull();
expect(result.actions).toEqual([]);
});
it('crash path: post-loop step throws → cleanup(crash), commitHash=null', async () => {
const mocks = buildMocks();
// Force the cross-ref reconciler to throw, escaping into the outer try/catch and
// landing in the crash branch.
mocks.knowledgeSlRefsRepository.syncFromWiki.mockRejectedValue(new Error('db down'));
// squashMergeIntoMain shouldn't even be reached.
mocks.gitService.squashMergeIntoMain.mockRejectedValue(new Error('should not be called after crash'));
// Need a wiki action to trigger the cross-ref code path. Easiest: have the LLM mock
// not push actions, so syncFromWiki is never called and crash won't happen here.
// Instead, force the squash to throw.
mocks.knowledgeSlRefsRepository.syncFromWiki.mockResolvedValue({ inserted: 0, deleted: 0 });
mocks.gitService.squashMergeIntoMain.mockRejectedValue(new Error('git crashed'));
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'crash', expect.any(Object));
expect(result.commitHash).toBeNull();
});
});
describe('MemoryAgentService.ingest — concurrency regression', () => {
it('two parallel ingest() calls produce distinct squash commits (no absorption)', async () => {
// FIFO lock: each acquisition chains onto the previous holder's release. This is the
// same shape as production withLock — the test asserts that two parallel ingests
// sequence both their phase-1 (worktree create) and phase-3 (squash merge) calls
// without deadlocking, and produce distinct commits.
let chain: Promise<void> = Promise.resolve();
const lockingService = {
withLock: vi.fn().mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
const previous = chain;
let releaseMe!: () => void;
chain = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await previous;
try {
return await fn();
} finally {
releaseMe();
}
}),
};
let createCount = 0;
const sessionWorktreeService = {
create: vi.fn().mockImplementation((chatId: string) => {
createCount += 1;
return Promise.resolve({
chatId,
workdir: `/tmp/wt/session-${chatId}`,
branch: `session/${chatId}`,
baseSha: 'basesha',
createdAt: new Date(),
git: { revParseHead: vi.fn().mockResolvedValue('basesha') },
config: { writeFile: vi.fn() },
});
}),
cleanup: vi.fn().mockResolvedValue(undefined),
};
let mergeCount = 0;
const gitService = {
revParseHead: vi.fn().mockResolvedValue('basesha'),
squashMergeIntoMain: vi.fn().mockImplementation(() => {
mergeCount += 1;
return Promise.resolve({
ok: true,
squashSha: `sha-${mergeCount}`,
touchedPaths: [`${mergeCount}.yaml`],
});
}),
};
const mocksA = buildMocks({ lockingService, sessionWorktreeService, gitService });
const mocksB = buildMocks({ lockingService, sessionWorktreeService, gitService });
const svcA = buildService(mocksA);
const svcB = buildService(mocksB);
const [a, b] = await Promise.all([
svcA.ingest({ ...baseInput, chatId: 'chat-A' }),
svcB.ingest({ ...baseInput, chatId: 'chat-B' }),
]);
expect(createCount).toBe(2);
expect(gitService.squashMergeIntoMain).toHaveBeenCalledTimes(2);
expect(a.commitHash).not.toBeNull();
expect(b.commitHash).not.toBeNull();
expect(a.commitHash).not.toBe(b.commitHash);
});
});

View file

@ -1,475 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { validateSingleSource } from '../../context/sl/tools/sl-warehouse-validation.js';
import { createTouchedSlSources, hasTouchedSlSource } from '../../context/tools/touched-sl-sources.js';
import { detectCaptureSignals, isWorthAnalyzing } from './capture-signals.js';
import { MemoryAgentService } from './memory-agent.service.js';
const passthroughValidator = {
validateSingleSource: (d: unknown, c: string, n: string) => validateSingleSource(d as never, c, n),
} as never;
describe('MemoryAgentService.detectCaptureSignals', () => {
it('fires sl on a long user message + SQL aggregate in assistant message', () => {
const userMessage = `${'A'.repeat(120)} show me revenue by month`;
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage,
assistantMessage: 'SELECT SUM(amount) FROM orders GROUP BY month',
});
expect(result.sl).toBe(true);
expect(result.reasons).toContain('sql aggregate in assistant message');
});
it('does NOT fire sl from aggregate alone when user message is short', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'show revenue',
assistantMessage: 'SELECT SUM(amount) FROM orders',
});
expect(result.sl).toBe(false);
});
it('fires sl on definition keywords in user message regardless of length', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'going forward exclude cancelled orders from revenue',
});
expect(result.sl).toBe(true);
expect(result.reasons).toContain('sl-style definition keyword in user message');
});
it('fires knowledge on a definition keyword in user message', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'BYOL stands for Bring Your Own Lab',
});
expect(result.knowledge).toBe(true);
expect(result.reasons).toContain('definition keyword in user message');
});
it('fires both sl and knowledge when both signals hit', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'going forward, define revenue as sum of paid orders',
});
expect(result.sl).toBe(true);
expect(result.knowledge).toBe(true);
});
it('fires neither for a plain ad-hoc question', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'how many users signed up last week?',
assistantMessage: '12 users.',
});
expect(result.sl).toBe(false);
expect(result.knowledge).toBe(false);
expect(result.reasons).toEqual([]);
});
it('fires knowledge when assistant emits a markdown definition table', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'list our protocols',
assistantMessage: '| Term | Definition |\n|---|---|\n| TRT | Testosterone Replacement Therapy |',
});
expect(result.knowledge).toBe(true);
expect(result.reasons).toContain('definition table in assistant message');
});
it('accepts JOIN and CTE-style aggregates as sl signals', () => {
const userMessage = 'B'.repeat(150);
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage,
assistantMessage: 'WITH base AS (SELECT * FROM x) SELECT * FROM base',
});
expect(result.sl).toBe(true);
});
it('reasons array is empty when no signal fires', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'hello',
});
expect(result.reasons).toEqual([]);
});
it('detects LookML dialect from view/measure structural keywords', () => {
const result = detectCaptureSignals({
userId: 'u',
chatId: 'c',
userMessage: 'ingest this',
assistantMessage:
'view: fct_labs {\n sql_table_name: analytics.fct_labs ;;\n measure: count_lab_orders { type: count }\n}',
});
expect(result.dialect).toBe('lookml');
expect(result.sl).toBe(true);
expect(result.reasons).toContain('lookml structure in assistant message');
});
});
describe('MemoryAgentService.isWorthAnalyzing (C1 + F1)', () => {
const baseInput = (assistantMessage: string) => ({
userId: 'u',
chatId: 'c',
userMessage: 'Ingest the following content into memory.',
assistantMessage,
});
it('skips a pure LookML wrapper (only view + sql_table_name + dimensions + measure: count)', () => {
const wrapper = `view: timeline {
sql_table_name: analytics.timeline ;;
dimension_group: date { type: time; description: "m/d/Y" }
dimension: notes { type: string; description: "notes" }
measure: count { type: count }
}`;
expect(isWorthAnalyzing(baseInput(wrapper))).toBe(false);
});
it('keeps a LookML view with a non-count aggregate (count_distinct, sum, avg, …)', () => {
const real = `view: fct_labs {
sql_table_name: analytics.fct_labs ;;
measure: count_lab_orders { type: count }
measure: count_distinct_patients { type: count_distinct; sql: \${admin_user_id} ;; }
}`;
expect(isWorthAnalyzing(baseInput(real))).toBe(true);
});
it('keeps a LookML view with derived_table even if it has no non-count measures', () => {
const derived = `view: lab_results {
derived_table: { sql: SELECT * FROM analytics.raw WHERE status = 'final' ;; }
dimension: lab_order_id { primary_key: yes; type: string }
measure: count { type: count }
}`;
expect(isWorthAnalyzing(baseInput(derived))).toBe(true);
});
it('keeps a LookML view with sql_always_where', () => {
const enforced = `view: rpt_daily_braze_email {
sql_table_name: analytics.fct_email_sends ;;
sql_always_where: \${TABLE}.channel = 'braze' ;;
measure: count { type: count }
}`;
expect(isWorthAnalyzing(baseInput(enforced))).toBe(true);
});
it('keeps a LookML view with a join: block', () => {
const joined = `view: fct_labs {
sql_table_name: analytics.fct_labs ;;
join: dim_customers {
sql_on: \${fct_labs.admin_user_id} = \${dim_customers.admin_user_id} ;;
relationship: many_to_one
}
}`;
expect(isWorthAnalyzing(baseInput(joined))).toBe(true);
});
});
describe('MemoryAgentService.reconcileCrossRefs', () => {
type Action = { target: 'wiki' | 'sl'; type: 'created' | 'updated' | 'removed'; key: string; detail: string };
const buildService = (overrides: {
readPage?: ReturnType<typeof vi.fn>;
syncFromWiki?: ReturnType<typeof vi.fn>;
}) => {
const wikiService = {
readPage: overrides.readPage ?? vi.fn(),
};
const knowledgeSlRefsRepository = {
syncFromWiki: overrides.syncFromWiki ?? vi.fn().mockResolvedValue({ inserted: 0, deleted: 0 }),
};
const svc = new MemoryAgentService({
settings: {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
llm: { memoryIngestionModel: 'test-model' },
},
promptService: undefined as never,
skillsRegistry: undefined as never,
wikiService: wikiService as never,
knowledgeIndex: undefined as never,
knowledgeSlRefs: knowledgeSlRefsRepository as never,
semanticLayerService: undefined as never,
slSearchService: undefined as never,
connections: undefined as never,
rootFileStore: undefined as never,
gitService: undefined as never,
lockingService: undefined as never,
slSourcesRepository: undefined as never,
sessionWorktreeService: undefined as never,
semanticLayerSourceReconciler: undefined as never,
agentRunner: undefined as never,
slValidator: undefined as never,
toolsetFactory: undefined as never,
});
return { svc, wikiService, knowledgeSlRefsRepository };
};
const session = {
userId: 'u',
chatId: 'c',
userMessage: 'test',
connectionId: 'conn-1',
userScopedEnabled: false,
forceGlobalScope: false,
touchedSlSources: createTouchedSlSources(),
preHead: null,
};
it('projects a wiki page.sl_refs into knowledge_sl_refs via syncFromWiki', async () => {
const { svc, knowledgeSlRefsRepository } = buildService({
readPage: vi.fn().mockResolvedValue({
pageKey: 'byol-definition',
frontmatter: { summary: 'byol', sl_refs: ['fct_labs', 'lab_results'] },
content: 'body',
}),
syncFromWiki: vi.fn().mockResolvedValue({ inserted: 2, deleted: 0 }),
});
const actions: Action[] = [{ target: 'wiki', type: 'created', key: 'byol-definition', detail: '' }];
const synced = await svc.reconcileCrossRefs(actions, session);
expect(synced).toBe(2);
expect(knowledgeSlRefsRepository.syncFromWiki).toHaveBeenCalledWith({
wikiPageKey: 'byol-definition',
wikiScope: 'GLOBAL',
wikiScopeId: null,
refs: [
{ connectionId: 'conn-1', sourceName: 'fct_labs' },
{ connectionId: 'conn-1', sourceName: 'lab_results' },
],
});
});
it('skips sync when the action has no connectionId in session', async () => {
const { svc, knowledgeSlRefsRepository } = buildService({
readPage: vi.fn().mockResolvedValue({
pageKey: 'byol-definition',
frontmatter: { summary: 'byol', sl_refs: ['fct_labs'] },
content: 'body',
}),
});
const actions: Action[] = [{ target: 'wiki', type: 'created', key: 'byol-definition', detail: '' }];
const synced = await svc.reconcileCrossRefs(actions, { ...session, connectionId: undefined });
expect(synced).toBe(0);
expect(knowledgeSlRefsRepository.syncFromWiki).not.toHaveBeenCalled();
});
it('syncs an empty sl_refs list — clearing any stale rows for that wiki', async () => {
const { svc, knowledgeSlRefsRepository } = buildService({
readPage: vi.fn().mockResolvedValue({
pageKey: 'byol-definition',
frontmatter: { summary: 'byol' },
content: 'body',
}),
syncFromWiki: vi.fn().mockResolvedValue({ inserted: 0, deleted: 1 }),
});
const actions: Action[] = [{ target: 'wiki', type: 'updated', key: 'byol-definition', detail: '' }];
const synced = await svc.reconcileCrossRefs(actions, session);
expect(synced).toBe(1);
expect(knowledgeSlRefsRepository.syncFromWiki).toHaveBeenCalledWith({
wikiPageKey: 'byol-definition',
wikiScope: 'GLOBAL',
wikiScopeId: null,
refs: [],
});
});
it('normalizes dotted sl_refs to bare source names, dedupes (H)', async () => {
const { svc, knowledgeSlRefsRepository } = buildService({
readPage: vi.fn().mockResolvedValue({
pageKey: 'fct-labs-overview',
frontmatter: {
summary: 'fct_labs',
sl_refs: ['fct_labs', 'fct_labs.count_lab_orders', 'fct_labs.count_distinct_patients', 'lab_results'],
},
content: 'body',
}),
syncFromWiki: vi.fn().mockResolvedValue({ inserted: 2, deleted: 0 }),
});
const actions: Action[] = [{ target: 'wiki', type: 'created', key: 'fct-labs-overview', detail: '' }];
await svc.reconcileCrossRefs(actions, session);
expect(knowledgeSlRefsRepository.syncFromWiki).toHaveBeenCalledWith({
wikiPageKey: 'fct-labs-overview',
wikiScope: 'GLOBAL',
wikiScopeId: null,
refs: [
{ connectionId: 'conn-1', sourceName: 'fct_labs' },
{ connectionId: 'conn-1', sourceName: 'lab_results' },
],
});
});
it('ignores sl-only actions — the DB index is driven from the wiki side', async () => {
const { svc, knowledgeSlRefsRepository } = buildService({});
const actions: Action[] = [{ target: 'sl', type: 'updated', key: 'fct_labs', detail: '' }];
const synced = await svc.reconcileCrossRefs(actions, session);
expect(synced).toBe(0);
expect(knowledgeSlRefsRepository.syncFromWiki).not.toHaveBeenCalled();
});
});
describe('MemoryAgentService.gateRevertInvalidSources (J3)', () => {
type Action = { target: 'wiki' | 'sl'; type: 'created' | 'updated' | 'removed'; key: string; detail: string };
// Build a service with the minimal deps the gate needs: semanticLayerService
// (readSourceFile, loadSource, writeSource for revert), dataSourcesService
// (executeQuery for dry-run), configService (writeFile/deleteFile for revert),
// gitService (getFileAtCommit).
const buildService = (overrides: {
readSourceFile?: ReturnType<typeof vi.fn>;
executeQuery?: ReturnType<typeof vi.fn>;
writeFile?: ReturnType<typeof vi.fn>;
deleteFile?: ReturnType<typeof vi.fn>;
getFileAtCommit?: ReturnType<typeof vi.fn>;
}) => {
const semanticLayerService = {
readSourceFile: overrides.readSourceFile ?? vi.fn(),
isManifestBacked: vi.fn().mockResolvedValue(false),
};
const connections = {
listEnabledConnections: vi.fn().mockResolvedValue([]),
getConnectionById: vi.fn().mockResolvedValue({
id: 'conn-1',
name: 'Warehouse',
connectionType: 'POSTGRESQL',
}),
executeQuery: overrides.executeQuery ?? vi.fn(),
};
const configService = {
writeFile: overrides.writeFile ?? vi.fn().mockResolvedValue({}),
deleteFile: overrides.deleteFile ?? vi.fn().mockResolvedValue({}),
};
const gitService = {
getFileAtCommit: overrides.getFileAtCommit ?? vi.fn().mockRejectedValue(new Error('not present')),
};
const slSourcesRepository = {
deleteByConnectionAndName: vi.fn().mockResolvedValue(undefined),
};
const svc = new MemoryAgentService({
settings: {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
llm: { memoryIngestionModel: 'test-model' },
},
promptService: undefined as never,
skillsRegistry: undefined as never,
wikiService: undefined as never,
knowledgeIndex: undefined as never,
knowledgeSlRefs: undefined as never,
semanticLayerService: semanticLayerService as never,
slSearchService: undefined as never,
connections: connections as never,
rootFileStore: configService as never,
gitService: gitService as never,
lockingService: undefined as never,
slSourcesRepository: slSourcesRepository as never,
sessionWorktreeService: undefined as never,
semanticLayerSourceReconciler: undefined as never,
agentRunner: undefined as never,
slValidator: passthroughValidator,
toolsetFactory: undefined as never,
});
return { svc, semanticLayerService, connections, configService, gitService, slSourcesRepository };
};
const session = {
userId: 'u',
chatId: 'c',
userMessage: 'test',
connectionId: 'conn-1',
userScopedEnabled: false,
forceGlobalScope: false,
touchedSlSources: createTouchedSlSources([{ connectionId: 'conn-1', sourceName: 'broken_source' }]),
preHead: null,
};
it('reverts (deletes) a source whose dry-run fails and drops its action', async () => {
const badYaml = `name: broken_source
source_type: sql
sql: |
SELECT fake_col FROM analytics.x
grain: [fake_col]
columns: [{name: fake_col, type: string}]
measures: []
joins: []
`;
const { svc, configService } = buildService({
readSourceFile: vi.fn().mockResolvedValue({ content: badYaml, path: 'x' }),
executeQuery: vi.fn().mockResolvedValue({
headers: [],
rows: [],
totalRows: 0,
error: 'Unrecognized name: fake_col',
}),
});
const actions: Action[] = [
{ target: 'sl', type: 'created', key: 'broken_source', detail: 'create' },
{ target: 'wiki', type: 'created', key: 'some_wiki', detail: 'wiki' },
];
const localSession = {
...session,
touchedSlSources: createTouchedSlSources([{ connectionId: 'conn-1', sourceName: 'broken_source' }]),
};
const reverted = await svc.gateRevertInvalidSources(localSession as never, actions);
expect(reverted).toEqual(['broken_source']);
expect(configService.deleteFile).toHaveBeenCalledWith(
'semantic-layer/conn-1/broken_source.yaml',
expect.any(String),
expect.any(String),
expect.any(String),
{ skipLock: true },
);
// Wiki action survives; SL action is scrubbed.
expect(actions.map((a) => `${a.target}:${a.key}`)).toEqual(['wiki:some_wiki']);
expect(hasTouchedSlSource(localSession.touchedSlSources, 'conn-1', 'broken_source')).toBe(false);
});
it('leaves a source alone when its dry-run passes', async () => {
const goodYaml = `name: good_source
source_type: sql
sql: |
SELECT id FROM analytics.x
grain: [id]
columns: [{name: id, type: string}]
measures: []
joins: []
`;
const { svc, configService } = buildService({
readSourceFile: vi.fn().mockResolvedValue({ content: goodYaml, path: 'x' }),
executeQuery: vi.fn().mockResolvedValue({ headers: ['id'], rows: [], totalRows: 0, error: null }),
});
const actions: Action[] = [{ target: 'sl', type: 'created', key: 'good_source', detail: 'create' }];
const localSession = {
...session,
touchedSlSources: createTouchedSlSources([{ connectionId: 'conn-1', sourceName: 'good_source' }]),
};
const reverted = await svc.gateRevertInvalidSources(localSession as never, actions);
expect(reverted).toEqual([]);
expect(configService.writeFile).not.toHaveBeenCalled();
expect(configService.deleteFile).not.toHaveBeenCalled();
expect(actions).toHaveLength(1);
});
});

View file

@ -1,199 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MemoryAgentInput, MemoryAgentResult } from '../../context/memory/types.js';
import type { MemoryAgentService } from '../../context/memory/memory-agent.service.js';
import { MemoryIngestService, type MemoryRunStorePort } from './memory-runs.js';
class InMemoryRunStore implements MemoryRunStorePort {
readonly rows = new Map<
string,
{
id: string;
status: 'running' | 'done' | 'error';
stage: string;
inputHash: string;
chatId: string | null;
outputSummary: MemoryAgentResult | null;
error: string | null;
}
>();
async createRunning(args: { inputHash: string; chatId?: string | null }): Promise<{ id: string }> {
const id = `run-${this.rows.size + 1}`;
this.rows.set(id, {
id,
status: 'running',
stage: 'queued',
inputHash: args.inputHash,
chatId: args.chatId ?? null,
outputSummary: null,
error: null,
});
return { id };
}
async markRunning(id: string, stage: string): Promise<void> {
const row = this.rows.get(id);
if (!row) {
throw new Error(`unknown run ${id}`);
}
row.stage = stage;
}
async markDone(id: string, outputSummary: MemoryAgentResult): Promise<void> {
const row = this.rows.get(id);
if (!row) {
throw new Error(`unknown run ${id}`);
}
row.status = 'done';
row.stage = 'done';
row.outputSummary = outputSummary;
}
async markError(id: string, error: string): Promise<void> {
const row = this.rows.get(id);
if (!row) {
throw new Error(`unknown run ${id}`);
}
row.status = 'error';
row.stage = 'error';
row.error = error;
}
async findById(id: string) {
return this.rows.get(id) ?? null;
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function buildService(): {
ingest: MemoryIngestService;
store: InMemoryRunStore;
memoryAgentIngest: ReturnType<typeof vi.fn>;
run: ReturnType<typeof deferred<MemoryAgentResult>>;
} {
const store = new InMemoryRunStore();
const run = deferred<MemoryAgentResult>();
const memoryAgentIngest = vi.fn<MemoryAgentService['ingest']>().mockReturnValue(run.promise);
const memoryAgent = { ingest: memoryAgentIngest };
return {
ingest: new MemoryIngestService({ memoryAgent, runs: store }),
store,
memoryAgentIngest,
run,
};
}
describe('MemoryIngestService', () => {
it('creates a run, executes memory ingest, and stores a done summary', async () => {
const result: MemoryAgentResult = {
signalDetected: true,
actions: [{ target: 'wiki', type: 'created', key: 'revenue', detail: 'captured revenue definition' }],
skillsLoaded: ['wiki_capture'],
commitHash: 'abc123',
};
const { ingest, store, memoryAgentIngest, run } = buildService();
const input: MemoryAgentInput = {
userId: 'user-1',
chatId: 'chat-1',
userMessage: 'Revenue means paid order value.',
assistantMessage: 'Captured.',
connectionId: '00000000-0000-0000-0000-000000000001',
};
const started = await ingest.ingest(input);
expect(started.runId).toBe('run-1');
expect(memoryAgentIngest).toHaveBeenCalledWith(input);
await expect(ingest.status(started.runId)).resolves.toMatchObject({
runId: 'run-1',
status: 'running',
stage: 'ingesting',
done: false,
});
run.resolve(result);
await ingest.waitForRun(started.runId);
const status = await ingest.status(started.runId);
expect(status).toEqual({
runId: 'run-1',
stage: 'done',
done: true,
status: 'done',
captured: {
wiki: ['revenue'],
sl: [],
xrefs: [],
},
error: null,
commitHash: 'abc123',
skillsLoaded: ['wiki_capture'],
signalDetected: true,
});
expect(store.rows.get('run-1')?.inputHash).toHaveLength(64);
});
it('stores no-signal ingests as done with empty captured arrays', async () => {
const { ingest, run } = buildService();
const started = await ingest.ingest({
userId: 'user-1',
chatId: 'chat-2',
userMessage: 'Thanks.',
});
run.resolve({
signalDetected: false,
actions: [],
skillsLoaded: [],
commitHash: null,
});
await ingest.waitForRun(started.runId);
await expect(ingest.status(started.runId)).resolves.toMatchObject({
done: true,
status: 'done',
captured: { wiki: [], sl: [], xrefs: [] },
signalDetected: false,
});
});
it('stores thrown errors and projects them as failed statuses', async () => {
const store = new InMemoryRunStore();
const memoryAgent = {
ingest: vi.fn<MemoryAgentService['ingest']>().mockRejectedValue(new Error('LLM provider missing')),
};
const ingest = new MemoryIngestService({ memoryAgent, runs: store });
const started = await ingest.ingest({
userId: 'user-1',
chatId: 'chat-3',
userMessage: 'Remember this.',
});
await ingest.waitForRun(started.runId);
await expect(ingest.status(started.runId)).resolves.toMatchObject({
done: true,
status: 'error',
stage: 'error',
captured: { wiki: [], sl: [], xrefs: [] },
error: 'LLM provider missing',
});
});
it('returns null for an unknown run id', async () => {
const { ingest } = buildService();
await expect(ingest.status('missing')).resolves.toBeNull();
});
});

View file

@ -1,198 +0,0 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { PromptService } from '../../context/prompts/prompt.service.js';
import { SkillsRegistryService } from '../../context/skills/skills-registry.service.js';
import { DEFAULT_SKILL_NAMES, promptNameFor } from '../../context/memory/capture-signals.js';
import type { MemoryAgentSourceType } from '../../context/memory/types.js';
const promptsDir = fileURLToPath(new URL('../../prompts', import.meta.url));
const skillsDir = fileURLToPath(new URL('../../skills', import.meta.url));
const memorySourceTypes: MemoryAgentSourceType[] = ['research', 'external_ingest', 'backfill'];
const expectedSkillHeadings: Record<string, string> = {
wiki_capture: '# Wiki Capture',
sl: '# Semantic Layer',
sl_capture: '# Semantic Layer',
};
const expectedAdapterSkillHeadings: Record<string, string> = {
historic_sql_patterns: '# Historic SQL Patterns',
historic_sql_table_digest: '# Historic SQL Table Digest',
live_database_ingest: '# Live Database Ingest',
looker_ingest: '# Looker Runtime Ingest',
lookml_ingest: '# LookML to KTX Semantic Layer',
metabase_ingest: '# Metabase to KTX Semantic Layer',
metricflow_ingest: '# MetricFlow to KTX Semantic Layer',
};
const verificationWriterSkills = [
'notion_synthesize',
'dbt_ingest',
'lookml_ingest',
'looker_ingest',
'metabase_ingest',
'metricflow_ingest',
'live_database_ingest',
'historic_sql_table_digest',
'historic_sql_patterns',
'wiki_capture',
'sl_capture',
] as const;
function forbiddenProductPattern() {
return new RegExp([['Kae', 'lio'].join(''), ['kae', 'lio'].join(''), ['KAE', 'LIO_'].join('')].join('|'));
}
function sqlExecutionCallBlocks(body: string): string[] {
const blocks: string[] = [];
const marker = 'sql_execution({';
let offset = 0;
while (offset < body.length) {
const start = body.indexOf(marker, offset);
if (start === -1) {
break;
}
const end = body.indexOf('})', start + marker.length);
blocks.push(body.slice(start, end === -1 ? start + marker.length : end + 2));
offset = start + marker.length;
}
return blocks;
}
describe('memory runtime assets', () => {
it('packages every memory-agent base prompt referenced by promptNameFor()', async () => {
const prompts = new PromptService({ promptsDir, partials: [] });
for (const sourceType of memorySourceTypes) {
const promptName = promptNameFor(sourceType);
const prompt = await prompts.loadPrompt(promptName);
expect(prompt).toContain('<role>');
expect(prompt).toContain('<workflow>');
expect(prompt).not.toMatch(forbiddenProductPattern());
}
});
it('packages the default memory capture skills referenced by DEFAULT_SKILL_NAMES', async () => {
const registry = new SkillsRegistryService({ skillsDir });
const skills = await registry.listSkills([...DEFAULT_SKILL_NAMES], 'memory_agent');
expect(skills.map((skill) => skill.name).sort()).toEqual(['sl', 'sl_capture', 'wiki_capture']);
for (const skill of skills) {
const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8');
const expectedHeading = expectedSkillHeadings[skill.name];
expect(expectedHeading).toBeDefined();
expect(body).toContain(expectedHeading);
expect(body).not.toMatch(forbiddenProductPattern());
}
});
it('keeps memory-only capture skills hidden from research callers', async () => {
const registry = new SkillsRegistryService({ skillsDir });
const skills = await registry.listSkills([...DEFAULT_SKILL_NAMES], 'research');
expect(skills.map((skill) => skill.name)).toEqual(['sl']);
});
it('packages ingest adapter skills referenced by bundled adapters', async () => {
const registry = new SkillsRegistryService({ skillsDir });
const skillNames = Object.keys(expectedAdapterSkillHeadings);
const skills = await registry.listSkills(skillNames, 'memory_agent');
expect(skills.map((skill) => skill.name).sort()).toEqual([...skillNames].sort());
for (const skill of skills) {
const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8');
expect(body).toContain(expectedAdapterSkillHeadings[skill.name]);
expect(body).not.toMatch(forbiddenProductPattern());
}
});
it('ships Looker runtime ingest guidance for warehouse target SL writes', async () => {
const body = await readFile(join(skillsDir, 'looker_ingest', 'SKILL.md'), 'utf-8');
expect(body).toContain('targetWarehouseConnectionId');
expect(body).toContain('targetTable.ok === true');
expect(body).toContain('targetTable.canonicalTable');
expect(body).toContain('source_tables preflight');
expect(body).toContain('emit_unmapped_fallback');
expect(body).toContain('no_connection_mapping');
expect(body).not.toContain('a standalone SL source only when raw evidence contains enough table or SQL structure');
});
it('ships Metabase guidance that avoids invalid joins for SQL-only card outputs', async () => {
const body = await readFile(join(skillsDir, 'metabase_ingest', 'SKILL.md'), 'utf-8');
expect(body).toContain('Do not declare a KTX join just because the card SQL joins that table internally');
expect(body).toContain('only when the card output exposes a local key that matches the target source grain');
expect(body).toContain('If `sl_discover` resolves the table, it is not outside the manifest');
expect(body).toContain('reason: "parse_error"');
expect(body).not.toContain('Tables outside the manifest');
expect(body).not.toContain('reason: "metabase_sql_untranslated"');
});
it('ships Notion guidance for physical-table fallbacks and duplicate wiki reconciliation', async () => {
const body = await readFile(join(skillsDir, 'notion_synthesize', 'SKILL.md'), 'utf-8');
expect(body).toContain('Notion `dataSourceCount` counts Notion databases/data sources only');
expect(body).toContain('Search existing wiki pages for the same `tables:` or `sl_refs:` frontmatter');
expect(body).toContain('no_physical_table');
});
it('packages LookML connection-mismatch SL gate guidance', async () => {
const body = await readFile(join(skillsDir, 'lookml_ingest', 'SKILL.md'), 'utf-8');
expect(body).toContain('[LOOKML SL WRITES DISALLOWED]');
expect(body).toContain('lookml_connection_mismatch');
expect(body).toContain('Do not call `sl_write_source` or `sl_edit_source`');
expect(body).toContain('LookML writes target the run connection directly');
});
it('ships identifier verification protocol in every synthesis writer skill', async () => {
for (const skillName of verificationWriterSkills) {
const body = await readFile(join(skillsDir, skillName, 'SKILL.md'), 'utf-8');
expect(body).toContain('## Identifier Verification Protocol');
expect(body).toMatch(/discover_data|entity_details/);
}
});
it('does not ship stale warehouse verification tool names or fictional identifiers', async () => {
for (const skillName of verificationWriterSkills) {
const body = await readFile(join(skillsDir, skillName, 'SKILL.md'), 'utf-8');
expect(body).not.toContain('orbit_analytics.customer');
expect(body).not.toContain('wiki_sl_search');
expect(body).not.toContain('sl_describe_table');
}
});
it('ships only the KTX connectionId sql_execution call shape in writer guidance', async () => {
const shared = await readFile(join(skillsDir, '_shared', 'identifier-verification.md'), 'utf-8');
const bodies = [{ name: '_shared/identifier-verification.md', body: shared }];
expect(shared).toContain('sql_execution({connectionId, sql: "SELECT DISTINCT');
expect(shared).toContain('sql_execution({connectionId, sql: "SELECT 1 FROM');
for (const skillName of verificationWriterSkills) {
const body = await readFile(join(skillsDir, skillName, 'SKILL.md'), 'utf-8');
bodies.push({ name: `${skillName}/SKILL.md`, body });
expect(body).toContain('sql_execution({connectionId');
expect(body).not.toContain('sql_execution({ sql');
expect(body).not.toContain('session shape');
expect(body).not.toContain('connection is already pinned by the ingest session');
}
for (const { name, body } of bodies) {
const calls = sqlExecutionCallBlocks(body);
expect(calls.length, `${name} should contain sql_execution guidance`).toBeGreaterThan(0);
expect(
calls.filter((call) => !call.includes('connectionId')),
`${name} has sql_execution calls without connectionId`,
).toEqual([]);
expect(body, `${name} has a connectionless multiline sql_execution call`).not.toMatch(
/sql_execution\(\{\s*sql\s*:/,
);
}
});
});