ktx/packages/cli/test/context/skills/skills-registry.service.test.ts

213 lines
8.4 KiB
TypeScript
Raw Permalink Normal View History

2026-05-10 23:12:26 +02:00
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
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
2026-05-26 08:49:05 +02:00
import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js';
2026-05-10 23:12:26 +02:00
describe('SkillsRegistryService', () => {
let service: SkillsRegistryService;
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'skills-registry-'));
service = new SkillsRegistryService({ skillsDir: tempDir });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
const writeSkill = async (dirName: string, body: string) => {
const dir = join(tempDir, dirName);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'SKILL.md'), body, 'utf-8');
};
describe('parseFrontmatter', () => {
it('parses name and description', () => {
const frontmatter = service.parseFrontmatter('---\nname: foo\ndescription: Bar baz\n---\n\n# body');
expect(frontmatter).toEqual({ name: 'foo', description: 'Bar baz' });
});
it('supports wrapped description continuation lines', () => {
const frontmatter = service.parseFrontmatter(
['---', 'name: sl', 'description: Line one', ' continuation of the description.', '---', '', '# body'].join(
'\n',
),
);
expect(frontmatter.name).toBe('sl');
expect(frontmatter.description).toContain('Line one');
expect(frontmatter.description).toContain('continuation');
});
it('returns empty fields when no frontmatter block', () => {
expect(service.parseFrontmatter('# just a heading')).toEqual({});
});
});
describe('stripFrontmatter', () => {
it('removes the frontmatter block and leading blank line', () => {
const body = '---\nname: x\ndescription: y\n---\n\n# Hello\n\nparagraph';
expect(service.stripFrontmatter(body)).toBe('# Hello\n\nparagraph');
});
it('is a no-op when no frontmatter exists', () => {
expect(service.stripFrontmatter('# hello')).toBe('# hello');
});
});
describe('discoverSkills', () => {
it('returns an empty map when the directory does not exist', async () => {
const catalog = await service.discoverSkills(join(tempDir, 'missing'));
expect(catalog.size).toBe(0);
});
it('discovers valid skills and skips invalid ones', async () => {
await writeSkill('sl', '---\nname: sl\ndescription: Semantic layer.\n---\n\n# SL');
await writeSkill('wiki_capture', '---\nname: wiki_capture\ndescription: Wiki capture.\n---\n\n# KC');
2026-05-10 23:12:26 +02:00
await writeSkill('broken', '# no frontmatter at all');
await mkdir(join(tempDir, 'not_a_skill'), { recursive: true });
const catalog = await service.discoverSkills(tempDir);
expect(catalog.size).toBe(2);
expect(catalog.get('sl')?.name).toBe('sl');
expect(catalog.get('wiki_capture')?.description).toContain('Wiki capture');
2026-05-10 23:12:26 +02:00
expect(catalog.has('broken')).toBe(false);
});
});
describe('buildSkillsPrompt', () => {
it('formats bullet list with name and description', () => {
const output = service.buildSkillsPrompt([
{ name: 'sl', description: 'Semantic layer.', path: '/tmp/sl' },
{ name: 'wiki_capture', description: 'Wiki capture.', path: '/tmp/kc' },
2026-05-10 23:12:26 +02:00
]);
expect(output).toContain('- sl: Semantic layer.');
expect(output).toContain('- wiki_capture: Wiki capture.');
2026-05-10 23:12:26 +02:00
expect(output).toContain('Use the `load_skill` tool');
});
it('returns empty string when no skills are available', () => {
expect(service.buildSkillsPrompt([])).toBe('');
});
it('appends the async capture note for the research caller', () => {
const output = service.buildSkillsPrompt(
[{ name: 'sl', description: 'Semantic layer.', path: '/tmp/sl' }],
'research',
);
expect(output).toContain('captured automatically by a post-turn memory agent');
expect(output).toContain('Focus on answering, not on saving');
});
it('does not append the note for memory_agent caller', () => {
const output = service.buildSkillsPrompt(
[{ name: 'sl_capture', description: 'Capture skill.', path: '/tmp/cap' }],
'memory_agent',
);
expect(output).not.toContain('captured automatically by a post-turn memory agent');
});
});
describe('parseFrontmatter callers field', () => {
it('parses inline-array form', () => {
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: [memory_agent]\n---\n');
expect(frontmatter.callers).toEqual(['memory_agent']);
});
it('parses comma-separated form', () => {
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: research, memory_agent\n---\n');
expect(frontmatter.callers).toEqual(['research', 'memory_agent']);
});
it('returns undefined when callers is absent', () => {
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\n---\n');
expect(frontmatter.callers).toBeUndefined();
});
it('drops unknown caller names with a warning', () => {
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers: [bogus, memory_agent]\n---\n');
expect(frontmatter.callers).toEqual(['memory_agent']);
});
it('returns undefined when the value is empty', () => {
const frontmatter = service.parseFrontmatter('---\nname: x\ndescription: y\ncallers:\n---\n');
expect(frontmatter.callers).toBeUndefined();
});
});
describe('listSkills and getSkill caller filter', () => {
beforeEach(async () => {
await writeSkill('sl', '---\nname: sl\ndescription: Open to all.\n---\n\n# SL');
await writeSkill(
'sl_capture',
'---\nname: sl_capture\ndescription: Memory-only capture skill.\ncallers: [memory_agent]\n---\n\n# Capture',
);
await writeSkill(
'wiki_capture',
'---\nname: wiki_capture\ndescription: Wiki capture.\ncallers: [memory_agent]\n---\n\n# KC',
2026-05-10 23:12:26 +02:00
);
service = new SkillsRegistryService({ skillsDir: tempDir });
});
it('research caller sees only open skills', async () => {
const skills = await service.listSkills('research');
expect(skills.map((skill) => skill.name).sort()).toEqual(['sl']);
});
it('memory_agent caller sees memory-only and open skills', async () => {
const skills = await service.listSkills('memory_agent');
expect(skills.map((skill) => skill.name).sort()).toEqual(['sl', 'sl_capture', 'wiki_capture']);
2026-05-10 23:12:26 +02:00
});
it('listSkills with names and caller intersects both filters', async () => {
const skills = await service.listSkills(['sl', 'sl_capture'], 'research');
expect(skills.map((skill) => skill.name)).toEqual(['sl']);
});
it('getSkill returns null for memory-only skill when caller is research', async () => {
const skill = await service.getSkill('sl_capture', 'research');
expect(skill).toBeNull();
});
it('getSkill returns the skill when caller has access', async () => {
const skill = await service.getSkill('sl_capture', 'memory_agent');
expect(skill?.name).toBe('sl_capture');
});
it('getSkill without caller returns the skill regardless of callers field', async () => {
const skill = await service.getSkill('sl_capture');
expect(skill?.name).toBe('sl_capture');
});
});
it('discovers skills from additional directories when the primary directory misses', async () => {
const extraDir = await mkdtemp(join(tmpdir(), 'skills-registry-extra-'));
try {
await mkdir(join(extraDir, 'wiki_capture'), { recursive: true });
2026-05-10 23:12:26 +02:00
await writeFile(
join(extraDir, 'wiki_capture', 'SKILL.md'),
2026-05-10 23:12:26 +02:00
[
'---',
'name: wiki_capture',
2026-05-10 23:12:26 +02:00
'description: Packaged knowledge capture skill.',
'callers: [memory_agent]',
'---',
'',
'# Wiki Capture',
2026-05-10 23:12:26 +02:00
].join('\n'),
'utf-8',
);
service = new SkillsRegistryService({ skillsDir: tempDir, additionalSkillDirs: [extraDir] });
const skills = await service.listSkills(['wiki_capture'], 'memory_agent');
2026-05-10 23:12:26 +02:00
expect(skills.map((skill) => skill.name)).toEqual(['wiki_capture']);
expect(skills[0]?.path).toBe(join(extraDir, 'wiki_capture'));
2026-05-10 23:12:26 +02:00
} finally {
await rm(extraDir, { recursive: true, force: true });
}
});
});