feat(FN-1451): complete Step 3 — align CLI hierarchy imports

This commit is contained in:
gsxdsm
2026-04-13 12:55:30 -07:00
parent b1c90013da
commit 2618eefc75
3 changed files with 150 additions and 16 deletions

View File

@@ -9,6 +9,8 @@ import { runAgentImport } from "./agent-import.js";
function makeAgentManifest(options: {
name: string;
title?: string;
slug?: string;
reportsTo?: string;
skills?: string[];
body?: string;
}): string {
@@ -16,6 +18,12 @@ function makeAgentManifest(options: {
if (options.title) {
lines.push(`title: ${options.title}`);
}
if (options.slug) {
lines.push(`slug: ${options.slug}`);
}
if (options.reportsTo) {
lines.push(`reportsTo: ${options.reportsTo}`);
}
if (options.skills && options.skills.length > 0) {
lines.push("skills:");
for (const skill of options.skills) {
@@ -55,6 +63,48 @@ function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
return basePath;
}
function createHierarchyCompanyDirectory(basePath: string): string {
mkdirSync(basePath, { recursive: true });
writeFileSync(
join(basePath, "COMPANY.md"),
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
);
mkdirSync(join(basePath, "agents", "ceo"), { recursive: true });
writeFileSync(
join(basePath, "agents", "ceo", "AGENTS.md"),
makeAgentManifest({
name: "CEO",
slug: "ceo",
title: "Chief Executive",
body: "Lead the company",
}),
);
mkdirSync(join(basePath, "agents", "vp-eng"), { recursive: true });
writeFileSync(
join(basePath, "agents", "vp-eng", "AGENTS.md"),
makeAgentManifest({
name: "VP Engineering",
slug: "vp-eng",
reportsTo: "ceo",
body: "Lead engineering",
}),
);
mkdirSync(join(basePath, "agents", "staff-eng"), { recursive: true });
writeFileSync(
join(basePath, "agents", "staff-eng", "AGENTS.md"),
makeAgentManifest({
name: "Staff Engineer",
reportsTo: "../vp-eng/AGENTS.md",
body: "Build systems",
}),
);
return basePath;
}
describe("agent-import", () => {
const tmpDir = join(tmpdir(), `kb-agent-import-test-${process.pid}`);
let createAgentMock: ReturnType<typeof vi.fn>;
@@ -63,7 +113,10 @@ describe("agent-import", () => {
beforeEach(() => {
mkdirSync(tmpDir, { recursive: true });
createAgentMock = vi.fn();
createAgentMock = vi.fn().mockImplementation(async (input: any) => ({
id: `agent-${String(input.name).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
...input,
}));
listAgentsMock = vi.fn().mockResolvedValue([]);
initMock = vi.fn().mockResolvedValue(undefined);
@@ -133,6 +186,55 @@ describe("agent-import", () => {
);
});
it("resolves imported manager hierarchy to created Fusion agent ids", async () => {
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-hierarchy"));
createAgentMock
.mockResolvedValueOnce({ id: "agent-ceo", name: "CEO" })
.mockResolvedValueOnce({ id: "agent-vp-eng", name: "VP Engineering" })
.mockResolvedValueOnce({ id: "agent-staff-eng", name: "Staff Engineer" });
await runAgentImport(companyDir);
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
name: "CEO",
role: "custom",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
name: "VP Engineering",
role: "custom",
reportsTo: "agent-ceo",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
name: "Staff Engineer",
role: "custom",
reportsTo: "agent-vp-eng",
}));
});
it("resolves skipped existing managers before importing their reports", async () => {
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-existing-manager"));
listAgentsMock.mockResolvedValue([
{
id: "agent-ceo-existing",
name: "CEO",
role: "custom",
metadata: { agentCompaniesSlug: "ceo" },
},
]);
await runAgentImport(companyDir, { skipExisting: true });
expect(createAgentMock).toHaveBeenCalledTimes(2);
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
name: "VP Engineering",
reportsTo: "agent-ceo-existing",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
name: "Staff Engineer",
reportsTo: "agent-vp-engineering",
}));
});
it("imports agents from a single AGENTS.md file", async () => {
const manifestPath = join(tmpDir, "AGENTS.md");
writeFileSync(

View File

@@ -14,7 +14,7 @@ import {
parseCompanyDirectory,
parseCompanyArchive,
parseSingleAgentManifest,
convertAgentCompanies,
prepareAgentCompaniesImport,
AgentCompaniesParseError,
} from "@fusion/core";
import type { AgentCreateInput } from "@fusion/core";
@@ -111,12 +111,23 @@ export async function runAgentImport(
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a) => a.name));
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
const conversionOptions = {
...(skipExisting ? { skipExisting: [...existingNames] } : {}),
existingAgents,
};
let companyName: string | undefined;
let agentCount = 0;
let teamCount = 0;
let inputs: AgentCreateInput[] = [];
let importItems: Array<{
manifestKey: string;
input: AgentCreateInput;
reportsTo?: {
raw: string;
resolvedAgentId?: string;
deferredManifestKey?: string;
};
}> = [];
let result: {
created: string[];
skipped: string[];
@@ -135,13 +146,13 @@ export async function runAgentImport(
companyName = pkg.company?.name;
agentCount = pkg.agents.length;
teamCount = pkg.teams.length;
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else if (isArchivePath(sourcePath)) {
const pkg = await parseCompanyArchive(sourcePath);
companyName = pkg.company?.name;
agentCount = pkg.agents.length;
teamCount = pkg.teams.length;
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else if (sourcePath.endsWith(".md")) {
const content = readFileSync(sourcePath, "utf-8");
const { manifest } = parseSingleAgentManifest(content);
@@ -154,7 +165,7 @@ export async function runAgentImport(
};
agentCount = pkg.agents.length;
teamCount = 0;
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else {
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
}
@@ -189,19 +200,40 @@ export async function runAgentImport(
// Create agents
const created: string[] = [];
const errors: Array<{ name: string; error: string }> = [...result.errors];
const createdAgentIdsByManifestKey = new Map<string, string>();
for (const input of inputs) {
for (const item of importItems) {
try {
// Double-check for duplicates if not using skipExisting
if (!skipExisting && existingNames.has(input.name)) {
errors.push({ name: input.name, error: "Agent with this name already exists" });
if (!skipExisting && existingNames.has(item.input.name)) {
errors.push({ name: item.input.name, error: "Agent with this name already exists" });
continue;
}
await agentStore.createAgent(input);
const input: AgentCreateInput = {
...item.input,
...(item.input.metadata ? { metadata: { ...item.input.metadata } } : {}),
};
if (item.reportsTo?.deferredManifestKey) {
const resolvedReportsTo = createdAgentIdsByManifestKey.get(item.reportsTo.deferredManifestKey);
if (!resolvedReportsTo) {
errors.push({
name: item.input.name,
error: `Could not resolve reportsTo reference "${item.reportsTo.raw}" because the manager was not created`,
});
continue;
}
input.reportsTo = resolvedReportsTo;
} else if (item.reportsTo?.resolvedAgentId) {
input.reportsTo = item.reportsTo.resolvedAgentId;
}
const agent = await agentStore.createAgent(input);
created.push(input.name);
createdAgentIdsByManifestKey.set(item.manifestKey, agent.id);
} catch (err) {
errors.push({ name: input.name, error: (err as Error).message });
errors.push({ name: item.input.name, error: (err as Error).message });
}
}

View File

@@ -180,10 +180,10 @@ function createUniqueManifestKey(baseKey: string, usedKeys: Set<string>): string
return candidate;
}
function topologicallySortImportPlanItems(
items: PreparedAgentCompaniesImportItem[],
function topologicallySortImportPlanItems<T extends PreparedAgentCompaniesImportItem>(
items: T[],
): {
orderedItems: PreparedAgentCompaniesImportItem[];
orderedItems: T[];
cycleErrors: Array<{ name: string; error: string }>;
} {
const byKey = new Map(items.map((item) => [item.manifestKey, item]));
@@ -209,7 +209,7 @@ function topologicallySortImportPlanItems(
const ready = items
.filter((item) => (indegree.get(item.manifestKey) ?? 0) === 0)
.sort((a, b) => a.index - b.index);
const orderedItems: PreparedAgentCompaniesImportItem[] = [];
const orderedItems: T[] = [];
while (ready.length > 0) {
const current = ready.shift();