ktx/packages/cli/test/context/skills/skills-registry.service.test.ts
Andrey Avtomonov 56985b7e09
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

212 lines
8.4 KiB
TypeScript

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';
import { SkillsRegistryService } from '../../../src/context/skills/skills-registry.service.js';
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');
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');
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' },
]);
expect(output).toContain('- sl: Semantic layer.');
expect(output).toContain('- wiki_capture: Wiki capture.');
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',
);
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']);
});
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 });
await writeFile(
join(extraDir, 'wiki_capture', 'SKILL.md'),
[
'---',
'name: wiki_capture',
'description: Packaged knowledge capture skill.',
'callers: [memory_agent]',
'---',
'',
'# Wiki Capture',
].join('\n'),
'utf-8',
);
service = new SkillsRegistryService({ skillsDir: tempDir, additionalSkillDirs: [extraDir] });
const skills = await service.listSkills(['wiki_capture'], 'memory_agent');
expect(skills.map((skill) => skill.name)).toEqual(['wiki_capture']);
expect(skills[0]?.path).toBe(join(extraDir, 'wiki_capture'));
} finally {
await rm(extraDir, { recursive: true, force: true });
}
});
});