Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions packages/agents/src/lib/__tests__/dependency-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { ARTIFACT_TYPE_VALUES, ARTIFACT_TYPES, artifactFrontmatterPath, type ArtifactType } from '../artifact-types.ts';
import { createSourceResolver } from '../content-sources.ts';
import { createSourceResolver, libraryResolver } from '../content-sources.ts';
import { type DirectArtifacts, resolveClosure } from '../dependency-resolver.ts';

describe(resolveClosure, () => {
Expand All @@ -24,7 +24,10 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'skill', 'people-report');
await writeArtifact(contentDir, 'subagent', 'canary');

const closure = await resolveClosure({ skill: ['people-report'], subagent: ['canary'] }, contentDir);
const closure = await resolveClosure(
{ skill: ['people-report'], subagent: ['canary'] },
libraryResolver(contentDir),
);

expect(closure).toEqual({ rulebooks: [], skills: ['people-report'], subagents: ['canary'] });
});
Expand All @@ -34,7 +37,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'subagent', 'canary');
await writeArtifact(contentDir, 'collection', 'recommended', { skill: ['people-report'], subagent: ['canary'] });

const closure = await resolveClosure({ collection: ['recommended'] }, contentDir);
const closure = await resolveClosure({ collection: ['recommended'] }, libraryResolver(contentDir));

expect(closure).toEqual({ rulebooks: [], skills: ['people-report'], subagents: ['canary'] });
});
Expand All @@ -45,7 +48,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'collection', 'base', { rulebook: ['typescript-conventions'] });
await writeArtifact(contentDir, 'collection', 'recommended', { collection: ['base'], skill: ['people-report'] });

const closure = await resolveClosure({ collection: ['recommended'] }, contentDir);
const closure = await resolveClosure({ collection: ['recommended'] }, libraryResolver(contentDir));

expect(closure).toEqual({ rulebooks: ['typescript-conventions'], skills: ['people-report'], subagents: [] });
});
Expand All @@ -56,7 +59,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'collection', 'right', { skill: ['shared'] });
await writeArtifact(contentDir, 'collection', 'top', { collection: ['left', 'right'] });

const closure = await resolveClosure({ collection: ['top'] }, contentDir);
const closure = await resolveClosure({ collection: ['top'] }, libraryResolver(contentDir));

expect(closure.skills).toEqual(['shared']);
});
Expand All @@ -65,7 +68,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'rulebook', 'typescript-conventions');
await writeArtifact(contentDir, 'skill', 'people-report', { rulebook: ['typescript-conventions'] });

const closure = await resolveClosure({ skill: ['people-report'] }, contentDir);
const closure = await resolveClosure({ skill: ['people-report'] }, libraryResolver(contentDir));

expect(closure).toEqual({ rulebooks: ['typescript-conventions'], skills: ['people-report'], subagents: [] });
});
Expand All @@ -74,15 +77,15 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'collection', 'a', { collection: ['b'] });
await writeArtifact(contentDir, 'collection', 'b', { collection: ['a'] });

await expect(resolveClosure({ collection: ['a'] }, contentDir)).rejects.toThrow(
await expect(resolveClosure({ collection: ['a'] }, libraryResolver(contentDir))).rejects.toThrow(
/cycle.*collection:a → collection:b → collection:a/s,
);
});

it('throws naming the type and slug when a referenced artifact is missing', async () => {
await writeArtifact(contentDir, 'collection', 'recommended', { skill: ['ghost'] });

await expect(resolveClosure({ collection: ['recommended'] }, contentDir)).rejects.toThrow(
await expect(resolveClosure({ collection: ['recommended'] }, libraryResolver(contentDir))).rejects.toThrow(
/skill "ghost" was not found/,
);
});
Expand All @@ -93,7 +96,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'subagent', 'canary');
await writeArtifact(contentDir, 'collection', 'all', '@library');

const closure = await resolveClosure({ collection: ['all'] }, contentDir);
const closure = await resolveClosure({ collection: ['all'] }, libraryResolver(contentDir));

expect(closure.rulebooks.toSorted()).toEqual(['typescript-conventions']);
expect(closure.skills.toSorted()).toEqual(['people-report']);
Expand All @@ -104,11 +107,11 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'skill', 'people-report');
await writeArtifact(contentDir, 'collection', 'all', '@library');

const before = await resolveClosure({ collection: ['all'] }, contentDir);
const before = await resolveClosure({ collection: ['all'] }, libraryResolver(contentDir));
expect(before.skills.toSorted()).toEqual(['people-report']);

await writeArtifact(contentDir, 'skill', 'classify-complexity');
const after = await resolveClosure({ collection: ['all'] }, contentDir);
const after = await resolveClosure({ collection: ['all'] }, libraryResolver(contentDir));

expect(after.skills.toSorted()).toEqual(['classify-complexity', 'people-report']);
});
Expand All @@ -119,7 +122,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'collection', 'right', '@library');
await writeArtifact(contentDir, 'collection', 'top', { collection: ['left', 'right'] });

const closure = await resolveClosure({ collection: ['top'] }, contentDir);
const closure = await resolveClosure({ collection: ['top'] }, libraryResolver(contentDir));

expect(closure.skills).toEqual(['shared']);
});
Expand All @@ -129,14 +132,16 @@ describe(resolveClosure, () => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, "---\nname: bad\nmembers: '@everything'\n---\n\n# bad\n", 'utf8');

await expect(resolveClosure({ collection: ['bad'] }, contentDir)).rejects.toThrow(/collection bad.*@everything/s);
await expect(resolveClosure({ collection: ['bad'] }, libraryResolver(contentDir))).rejects.toThrow(
/collection bad.*@everything/s,
);
});

it("pulls a subagent's injected skills into the closure without a dependencies edge", async () => {
await writeArtifact(contentDir, 'skill', 'anti-patterns');
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns']);

const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir);
const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, libraryResolver(contentDir));

expect(closure).toEqual({ rulebooks: [], skills: ['anti-patterns'], subagents: ['orchestrated-coder'] });
});
Expand All @@ -146,7 +151,7 @@ describe(resolveClosure, () => {
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns']);
await writeArtifact(contentDir, 'collection', 'recommended', { subagent: ['orchestrated-coder'] });

const closure = await resolveClosure({ collection: ['recommended'] }, contentDir);
const closure = await resolveClosure({ collection: ['recommended'] }, libraryResolver(contentDir));

expect(closure.skills).toEqual(['anti-patterns']);
expect(closure.subagents).toEqual(['orchestrated-coder']);
Expand All @@ -156,7 +161,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'skill', 'anti-patterns');
await writeSubagent(contentDir, 'orchestrated-coder', ['anti-patterns'], { skill: ['anti-patterns'] });

const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir);
const closure = await resolveClosure({ subagent: ['orchestrated-coder'] }, libraryResolver(contentDir));

expect(closure.skills).toEqual(['anti-patterns']);
});
Expand All @@ -165,15 +170,15 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'skill', 'loops', { subagent: ['coder'] });
await writeSubagent(contentDir, 'coder', ['loops']);

await expect(resolveClosure({ subagent: ['coder'] }, contentDir)).rejects.toThrow(
await expect(resolveClosure({ subagent: ['coder'] }, libraryResolver(contentDir))).rejects.toThrow(
/cycle.*subagent:coder → skill:loops → subagent:coder/s,
);
});

it('throws naming the skill when an injected skill is missing from the library', async () => {
await writeSubagent(contentDir, 'orchestrated-coder', ['ghost']);

await expect(resolveClosure({ subagent: ['orchestrated-coder'] }, contentDir)).rejects.toThrow(
await expect(resolveClosure({ subagent: ['orchestrated-coder'] }, libraryResolver(contentDir))).rejects.toThrow(
/skill "ghost" was not found/,
);
});
Expand All @@ -183,7 +188,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'skill', 'capture-event');
await writeArtifactWithBody(contentDir, 'skill', 'wrap-up', 'Invoke {skill:capture-event} to record it.');

const closure = await resolveClosure({ skill: ['wrap-up'] }, contentDir);
const closure = await resolveClosure({ skill: ['wrap-up'] }, libraryResolver(contentDir));

expect(closure.skills.toSorted()).toEqual(['capture-event', 'wrap-up']);
});
Expand All @@ -192,7 +197,7 @@ describe(resolveClosure, () => {
await writeArtifact(contentDir, 'subagent', 'planner');
await writeArtifactWithBody(contentDir, 'subagent', 'orchestrator', 'Dispatch {subagent:planner} first.');

const closure = await resolveClosure({ subagent: ['orchestrator'] }, contentDir);
const closure = await resolveClosure({ subagent: ['orchestrator'] }, libraryResolver(contentDir));

expect(closure.subagents.toSorted()).toEqual(['orchestrator', 'planner']);
});
Expand All @@ -202,15 +207,17 @@ describe(resolveClosure, () => {
await writeSkillPartial(contentDir, 'wrap-up', 'frag.md', 'Then invoke {skill:capture-event}.');
await writeArtifactWithBody(contentDir, 'skill', 'wrap-up', '# wrap-up\n\n<!-- include: _partials/frag.md / -->');

const closure = await resolveClosure({ skill: ['wrap-up'] }, contentDir);
const closure = await resolveClosure({ skill: ['wrap-up'] }, libraryResolver(contentDir));

expect(closure.skills.toSorted()).toEqual(['capture-event', 'wrap-up']);
});

it('fails the run when a body token names a non-existent artifact', async () => {
await writeArtifactWithBody(contentDir, 'skill', 'wrap-up', 'Invoke {skill:ghost}.');

await expect(resolveClosure({ skill: ['wrap-up'] }, contentDir)).rejects.toThrow(/skill "ghost" was not found/);
await expect(resolveClosure({ skill: ['wrap-up'] }, libraryResolver(contentDir))).rejects.toThrow(
/skill "ghost" was not found/,
);
});

it('deduplicates a slug named by both a body token and a dependencies edge', async () => {
Expand All @@ -219,7 +226,7 @@ describe(resolveClosure, () => {
skill: ['capture-event'],
});

const closure = await resolveClosure({ skill: ['wrap-up'] }, contentDir);
const closure = await resolveClosure({ skill: ['wrap-up'] }, libraryResolver(contentDir));

expect(closure.skills.toSorted()).toEqual(['capture-event', 'wrap-up']);
});
Expand All @@ -229,7 +236,7 @@ describe(resolveClosure, () => {
// skill is neither pulled into the closure nor existence-checked (here it does not exist, and the run succeeds).
await writeArtifactWithBody(contentDir, 'rulebook', 'some-rulebook', 'Invoke {skill:capture-event}.');

const closure = await resolveClosure({ rulebook: ['some-rulebook'] }, contentDir);
const closure = await resolveClosure({ rulebook: ['some-rulebook'] }, libraryResolver(contentDir));

expect(closure).toEqual({ rulebooks: ['some-rulebook'], skills: [], subagents: [] });
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import { resolveContentDir } from '../content-resolver.ts';
import { libraryResolver } from '../content-sources.ts';
import { resolveClosure } from '../dependency-resolver.ts';

// Asserts that the content library's invocation edges resolve: declaring a skill pulls the skills and subagents it
Expand All @@ -10,27 +11,27 @@ describe('library invocation edges', () => {
const contentDir = resolveContentDir();

it('pulls capture-event into capture-feedback via its body token', async () => {
const closure = await resolveClosure({ skill: ['capture-feedback'] }, contentDir);
const closure = await resolveClosure({ skill: ['capture-feedback'] }, libraryResolver(contentDir));

expect(closure.skills).toContain('capture-event');
});

it('pulls capture-feedback into collaborate, and capture-event transitively', async () => {
const closure = await resolveClosure({ skill: ['collaborate'] }, contentDir);
const closure = await resolveClosure({ skill: ['collaborate'] }, libraryResolver(contentDir));

expect(closure.skills).toContain('capture-feedback');
expect(closure.skills).toContain('capture-event');
});

it('pulls create-pr delegates and the changelog-writer they reach', async () => {
const closure = await resolveClosure({ skill: ['create-pr'] }, contentDir);
const closure = await resolveClosure({ skill: ['create-pr'] }, libraryResolver(contentDir));

expect(closure.skills).toEqual(expect.arrayContaining(['create-gh-pr', 'create-bitbucket-pr', 'summarize-change']));
expect(closure.subagents).toContain('changelog-writer');
});

it('pulls orchestrate dispatched subagents declared in frontmatter', async () => {
const closure = await resolveClosure({ skill: ['orchestrate'] }, contentDir);
const closure = await resolveClosure({ skill: ['orchestrate'] }, libraryResolver(contentDir));

expect(closure.subagents).toEqual(
expect.arrayContaining([
Expand All @@ -43,7 +44,7 @@ describe('library invocation edges', () => {
});

it('pulls refine-plan review subagents declared in frontmatter', async () => {
const closure = await resolveClosure({ skill: ['refine-plan'] }, contentDir);
const closure = await resolveClosure({ skill: ['refine-plan'] }, libraryResolver(contentDir));

expect(closure.subagents).toEqual(expect.arrayContaining(['plan-reviewer', 'plan-reviser']));
});
Expand Down
11 changes: 2 additions & 9 deletions packages/agents/src/lib/dependency-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';

import { ARTIFACT_TYPE_VALUES, artifactFrontmatterPath, type ArtifactType } from './artifact-types.ts';
import { describeSearchedLocations, libraryResolver, type SourceResolver } from './content-sources.ts';
import { describeSearchedLocations, type SourceResolver } from './content-sources.ts';
import {
type ArtifactDependencies,
readDependencies,
Expand Down Expand Up @@ -32,15 +32,8 @@ export interface ResolvedClosure {
* collection is a traversal-only node: its members are followed but the collection itself is dropped from the
* deployable result. A referenced artifact that resolves from no source or the library throws an error naming its
* type and slug and every location searched.
*
* @param sources The source resolver each artifact is resolved through, or a library directory string normalized to a
* library-only resolver (preserving the legacy library-only call sites unchanged).
*/
export async function resolveClosure(
direct: DirectArtifacts,
sources: SourceResolver | string,
): Promise<ResolvedClosure> {
const resolver = typeof sources === 'string' ? libraryResolver(sources) : sources;
export async function resolveClosure(direct: DirectArtifacts, resolver: SourceResolver): Promise<ResolvedClosure> {
const reached: Record<ArtifactType, Set<string>> = {
rulebook: new Set(),
skill: new Set(),
Expand Down
Loading