mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
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:
parent
924868841d
commit
56985b7e09
548 changed files with 5048 additions and 2228 deletions
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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*:/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue