mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
import { describe, test, expect, afterEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
composeResolvers,
|
|
composeManifests,
|
|
renderResolverMarkdown,
|
|
renderManifestJson,
|
|
writeMountsCache,
|
|
clearMountsCache,
|
|
__testing,
|
|
} from '../src/core/mounts-cache.ts';
|
|
import { HOST_BRAIN_ID, type MountEntry } from '../src/core/brain-registry.ts';
|
|
|
|
const toCleanup: string[] = [];
|
|
function mktmp(prefix = 'mounts-cache-'): string {
|
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
toCleanup.push(dir);
|
|
return dir;
|
|
}
|
|
afterEach(() => {
|
|
while (toCleanup.length > 0) {
|
|
const p = toCleanup.pop();
|
|
if (!p) continue;
|
|
try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
}
|
|
});
|
|
|
|
/** Build a tmp skills dir with a minimal RESOLVER.md + manifest.json + SKILL.md stubs. */
|
|
function makeSkillsDir(skills: Array<{ name: string; trigger: string; section?: string }>): string {
|
|
const dir = mktmp();
|
|
mkdirSync(join(dir, 'skills'), { recursive: true });
|
|
for (const s of skills) {
|
|
mkdirSync(join(dir, 'skills', s.name), { recursive: true });
|
|
writeFileSync(join(dir, 'skills', s.name, 'SKILL.md'), `---\nname: ${s.name}\n---\n# ${s.name}\n`);
|
|
}
|
|
const resolverLines = ['# RESOLVER', ''];
|
|
const bySection = new Map<string, Array<{ name: string; trigger: string }>>();
|
|
for (const s of skills) {
|
|
const sec = s.section || 'Brain operations';
|
|
const bucket = bySection.get(sec) ?? [];
|
|
bucket.push(s);
|
|
bySection.set(sec, bucket);
|
|
}
|
|
for (const [sec, bucket] of bySection.entries()) {
|
|
resolverLines.push(`## ${sec}`, '', '| Trigger | Skill |', '|---------|-------|');
|
|
for (const s of bucket) {
|
|
resolverLines.push(`| ${s.trigger} | \`skills/${s.name}/SKILL.md\` |`);
|
|
}
|
|
resolverLines.push('');
|
|
}
|
|
writeFileSync(join(dir, 'skills', 'RESOLVER.md'), resolverLines.join('\n'));
|
|
writeFileSync(
|
|
join(dir, 'skills', 'manifest.json'),
|
|
JSON.stringify({ skills: skills.map(s => ({ name: s.name, path: `${s.name}/SKILL.md` })) }, null, 2),
|
|
);
|
|
return join(dir, 'skills');
|
|
}
|
|
|
|
function makeMount(id: string, skillsDir: string, enabled = true): MountEntry {
|
|
// skillsDir is the SKILLS dir; mount.path is the parent (repo root).
|
|
const repoRoot = join(skillsDir, '..');
|
|
return {
|
|
id,
|
|
path: repoRoot,
|
|
engine: 'pglite',
|
|
database_path: join(repoRoot, '.pg'),
|
|
enabled,
|
|
};
|
|
}
|
|
|
|
describe('composeResolvers — host only', () => {
|
|
test('empty world: empty output', () => {
|
|
const hostDir = mktmp();
|
|
const result = composeResolvers(hostDir, []);
|
|
expect(result.entries).toEqual([]);
|
|
expect(result.shadows).toEqual([]);
|
|
expect(result.ambiguities).toEqual([]);
|
|
});
|
|
|
|
test('host skills only: no namespace prefix, brainId=host', () => {
|
|
const hostSkills = makeSkillsDir([
|
|
{ name: 'query', trigger: 'search' },
|
|
{ name: 'enrich', trigger: 'enrich a page' },
|
|
]);
|
|
const result = composeResolvers(hostSkills, []);
|
|
expect(result.entries).toHaveLength(2);
|
|
expect(result.entries.every(e => e.brainId === HOST_BRAIN_ID)).toBe(true);
|
|
expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['enrich', 'query']);
|
|
});
|
|
});
|
|
|
|
describe('composeResolvers — mount skills', () => {
|
|
test('single mount: namespace prefix applied', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'yc-media ingest' }]);
|
|
const mount = makeMount('yc-media', mountSkills);
|
|
const result = composeResolvers(hostSkills, [mount]);
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0].qualifiedName).toBe('yc-media::ingest');
|
|
expect(result.entries[0].brainId).toBe('yc-media');
|
|
// absolutePath is join()-built, so it carries '\' on win32 — build the
|
|
// expected tail with join() rather than a POSIX literal.
|
|
expect(result.entries[0].absolutePath).toContain(join('/skills', 'ingest', 'SKILL.md'));
|
|
});
|
|
|
|
test('disabled mount is excluded', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
|
const mount = makeMount('disabled', mountSkills, false);
|
|
const result = composeResolvers(hostSkills, [mount]);
|
|
expect(result.entries).toEqual([]);
|
|
});
|
|
|
|
test('two mounts same skill → ambiguity (host does not define it)', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1 ingest' }]);
|
|
const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2 ingest' }]);
|
|
const m1 = makeMount('alpha', m1Skills);
|
|
const m2 = makeMount('beta', m2Skills);
|
|
const result = composeResolvers(hostSkills, [m1, m2]);
|
|
expect(result.ambiguities).toHaveLength(1);
|
|
expect(result.ambiguities[0].skillName).toBe('ingest');
|
|
expect(result.ambiguities[0].mountIds).toEqual(['alpha', 'beta']);
|
|
// Both entries still surface (via namespace form).
|
|
const names = result.entries.map(e => e.qualifiedName).sort();
|
|
expect(names).toEqual(['alpha::ingest', 'beta::ingest']);
|
|
});
|
|
|
|
test('host + mount same name → host wins BARE name, mount reachable via namespace', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host ingest' }]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount ingest' }]);
|
|
const mount = makeMount('yc-media', mountSkills);
|
|
const result = composeResolvers(hostSkills, [mount]);
|
|
// Both entries survive: host wins bare 'ingest', but 'yc-media::ingest'
|
|
// must remain routable (the whole point of namespace-qualified form).
|
|
expect(result.entries).toHaveLength(2);
|
|
const host = result.entries.find(e => e.brainId === HOST_BRAIN_ID);
|
|
const mnt = result.entries.find(e => e.brainId === 'yc-media');
|
|
expect(host?.qualifiedName).toBe('ingest');
|
|
expect(mnt?.qualifiedName).toBe('yc-media::ingest');
|
|
// Shadow recorded so doctor can warn about local-customizing a remote skill
|
|
expect(result.shadows).toHaveLength(1);
|
|
expect(result.shadows[0].skillName).toBe('ingest');
|
|
expect(result.shadows[0].shadowedMounts).toHaveLength(1);
|
|
expect(result.shadows[0].shadowedMounts[0].mountId).toBe('yc-media');
|
|
// Not flagged as ambiguity — host wins the bare name cleanly
|
|
expect(result.ambiguities).toEqual([]);
|
|
});
|
|
|
|
test('host shadows two mounts → all three entries survive, shadow tracks both mounts', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
|
|
const m1Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm1' }]);
|
|
const m2Skills = makeSkillsDir([{ name: 'ingest', trigger: 'm2' }]);
|
|
const result = composeResolvers(hostSkills, [makeMount('a', m1Skills), makeMount('b', m2Skills)]);
|
|
// Host entry + both namespaced mount entries
|
|
expect(result.entries).toHaveLength(3);
|
|
expect(result.entries.map(e => e.qualifiedName).sort()).toEqual(['a::ingest', 'b::ingest', 'ingest']);
|
|
// No ambiguity: host wins bare name
|
|
expect(result.ambiguities).toEqual([]);
|
|
expect(result.shadows).toHaveLength(1);
|
|
expect(result.shadows[0].shadowedMounts.map(m => m.mountId).sort()).toEqual(['a', 'b']);
|
|
});
|
|
|
|
test('mount without RESOLVER.md contributes no entries (not an error)', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
// Create a mount path with no skills/RESOLVER.md inside
|
|
const emptyRoot = mktmp();
|
|
const mount: MountEntry = {
|
|
id: 'empty', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
|
|
};
|
|
const result = composeResolvers(hostSkills, [mount]);
|
|
expect(result.entries).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('composeManifests', () => {
|
|
test('host only: entries preserved without namespace', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
|
const result = composeManifests(hostSkills, []);
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0].name).toBe('query');
|
|
expect(result.entries[0].brainId).toBe(HOST_BRAIN_ID);
|
|
});
|
|
|
|
test('mount entries get namespace prefix (Codex finding #8)', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
|
const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
|
|
expect(result.entries).toHaveLength(1);
|
|
expect(result.entries[0].name).toBe('yc-media::ingest');
|
|
expect(result.entries[0].brainId).toBe('yc-media');
|
|
});
|
|
|
|
test('manifest keeps namespace-qualified mount entry even when host shadows', () => {
|
|
// Bare-name resolution is composeResolvers' job. The manifest lists every
|
|
// addressable skill by its canonical name, so the namespace-qualified
|
|
// form must survive regardless of host shadow. This matches the
|
|
// corresponding composeResolvers shadow test.
|
|
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'host' }]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'mount' }]);
|
|
const result = composeManifests(hostSkills, [makeMount('yc-media', mountSkills)]);
|
|
expect(result.entries).toHaveLength(2);
|
|
expect(result.entries.map(e => e.name).sort()).toEqual(['ingest', 'yc-media::ingest']);
|
|
const host = result.entries.find(e => e.name === 'ingest');
|
|
const mnt = result.entries.find(e => e.name === 'yc-media::ingest');
|
|
expect(host?.brainId).toBe(HOST_BRAIN_ID);
|
|
expect(mnt?.brainId).toBe('yc-media');
|
|
});
|
|
|
|
test('disabled mount excluded', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
|
const result = composeManifests(hostSkills, [makeMount('off', mountSkills, false)]);
|
|
expect(result.entries).toEqual([]);
|
|
});
|
|
|
|
test('missing manifest.json in mount → mount contributes nothing (no crash)', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const emptyRoot = mktmp();
|
|
mkdirSync(join(emptyRoot, 'skills'), { recursive: true });
|
|
// No manifest.json written
|
|
const mount: MountEntry = {
|
|
id: 'bare', path: emptyRoot, engine: 'pglite', database_path: `${emptyRoot}/.pg`, enabled: true,
|
|
};
|
|
expect(() => composeManifests(hostSkills, [mount])).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('renderResolverMarkdown', () => {
|
|
test('produces stable markdown with brain column', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'search' }]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'media ingest' }]);
|
|
const composed = composeResolvers(hostSkills, [makeMount('yc-media', mountSkills)]);
|
|
const md = renderResolverMarkdown(composed);
|
|
expect(md).toContain('# GBrain Skill Resolver (aggregated)');
|
|
expect(md).toContain('Auto-generated');
|
|
expect(md).toContain('| Trigger | Skill | Brain |');
|
|
expect(md).toContain('| search');
|
|
expect(md).toContain('| host |');
|
|
expect(md).toContain('| media ingest');
|
|
expect(md).toContain('| yc-media |');
|
|
});
|
|
|
|
test('shadows section appears only when shadows exist', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
|
const mountSkills = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
|
const composed = composeResolvers(hostSkills, [makeMount('m', mountSkills)]);
|
|
const md = renderResolverMarkdown(composed);
|
|
expect(md).toContain('## Shadows');
|
|
expect(md).toContain('`ingest` (host) shadows');
|
|
});
|
|
|
|
test('ambiguities section appears only when ambiguities exist', () => {
|
|
const hostSkills = makeSkillsDir([]);
|
|
const m1 = makeSkillsDir([{ name: 'ingest', trigger: 'x' }]);
|
|
const m2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
|
const composed = composeResolvers(hostSkills, [makeMount('a', m1), makeMount('b', m2)]);
|
|
const md = renderResolverMarkdown(composed);
|
|
expect(md).toContain('## Ambiguous bare names');
|
|
expect(md).toContain('`ingest`');
|
|
});
|
|
});
|
|
|
|
describe('renderManifestJson', () => {
|
|
test('produces parseable JSON with expected shape', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
|
const composed = composeManifests(hostSkills, []);
|
|
const json = renderManifestJson(composed);
|
|
const parsed = JSON.parse(json);
|
|
expect(parsed.generated_by).toBe('gbrain mounts');
|
|
expect(parsed.skills).toHaveLength(1);
|
|
expect(parsed.skills[0].name).toBe('query');
|
|
expect(parsed.skills[0].brain).toBe(HOST_BRAIN_ID);
|
|
});
|
|
});
|
|
|
|
describe('writeMountsCache + clearMountsCache', () => {
|
|
test('writes both RESOLVER.md and manifest.json atomically', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
|
const cacheDir = mktmp();
|
|
const { resolverPath, manifestPath } = writeMountsCache(hostSkills, [], { cacheDir });
|
|
expect(existsSync(resolverPath)).toBe(true);
|
|
expect(existsSync(manifestPath)).toBe(true);
|
|
const md = readFileSync(resolverPath, 'utf-8');
|
|
expect(md).toContain('# GBrain Skill Resolver (aggregated)');
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
expect(manifest.skills).toHaveLength(1);
|
|
});
|
|
|
|
test('rewriting overwrites cleanly', () => {
|
|
const hostSkills1 = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
|
const cacheDir = mktmp();
|
|
writeMountsCache(hostSkills1, [], { cacheDir });
|
|
const hostSkills2 = makeSkillsDir([{ name: 'ingest', trigger: 'y' }]);
|
|
writeMountsCache(hostSkills2, [], { cacheDir });
|
|
const manifest = JSON.parse(readFileSync(join(cacheDir, 'manifest.json'), 'utf-8'));
|
|
expect(manifest.skills.map((s: { name: string }) => s.name)).toEqual(['ingest']);
|
|
});
|
|
|
|
test('clearMountsCache removes the directory', () => {
|
|
const hostSkills = makeSkillsDir([{ name: 'query', trigger: 'x' }]);
|
|
const cacheDir = mktmp();
|
|
writeMountsCache(hostSkills, [], { cacheDir });
|
|
expect(existsSync(cacheDir)).toBe(true);
|
|
clearMountsCache({ cacheDir });
|
|
expect(existsSync(cacheDir)).toBe(false);
|
|
});
|
|
|
|
test('clearMountsCache on missing dir is a no-op', () => {
|
|
const fake = join(tmpdir(), `does-not-exist-${Date.now()}`);
|
|
expect(() => clearMountsCache({ cacheDir: fake })).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('skillNameFromRelPath', () => {
|
|
test('extracts name from skills/ prefix', () => {
|
|
expect(__testing.skillNameFromRelPath('skills/query/SKILL.md')).toBe('query');
|
|
expect(__testing.skillNameFromRelPath('skills/yc-media/SKILL.md')).toBe('yc-media');
|
|
});
|
|
test('handles name without skills/ prefix', () => {
|
|
expect(__testing.skillNameFromRelPath('query/SKILL.md')).toBe('query');
|
|
});
|
|
});
|