feat(FN-1174): migrate agent import flow to agent companies format

- Replace legacy companies.sh parser/types with the new agent companies parser and exported core types.
- Update CLI agent import wiring and tests to consume the new import source handling.
- Update dashboard agent import modal, API client, and import route tests to support archive-based sources.
- Add release-note changesets and lockfile updates for the @gsxdsm/fusion patch release.
This commit is contained in:
gsxdsm
2026-04-08 10:35:28 -07:00
parent e076be355c
commit c36d54b757
21 changed files with 660 additions and 1999 deletions

View File

@@ -5,26 +5,15 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
// ── Mock @fusion/core for agent import ────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockListAgents = vi.fn().mockResolvedValue([]);
const mockCreateAgent = vi.fn();
const mockParseCompaniesShManifest = vi.fn();
const mockConvertCompaniesShAgents = vi.fn();
const mockParseCompanyDirectory = vi.fn();
const mockParseCompanyArchive = vi.fn();
const mockParseAgentManifest = vi.fn();
const mockParseSingleAgentManifest = vi.fn();
const mockConvertAgentCompanies = vi.fn();
class MockCompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
}
class MockAgentCompaniesParseError extends Error {
constructor(message: string) {
super(message);
@@ -39,26 +28,21 @@ vi.mock("@fusion/core", () => {
listAgents = mockListAgents;
createAgent = mockCreateAgent;
},
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
parseAgentManifest: (...args: unknown[]) => mockParseAgentManifest(...args),
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
CompaniesShParseError: MockCompaniesShParseError,
AgentCompaniesParseError: MockAgentCompaniesParseError,
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1189-test";
return "/tmp/fn-1174-test";
}
getFusionDir(): string {
return "/tmp/fn-1189-test/.fusion";
return "/tmp/fn-1174-test/.fusion";
}
getDatabase() {
@@ -73,25 +57,12 @@ class MockStore extends EventEmitter {
}
}
// ── Helpers ───────────────────────────────────────────────────────────
function encodeManifest(agents: unknown[]): string {
return Buffer.from(JSON.stringify(agents)).toString("base64");
}
function makeScript(companyName: string, agents: unknown[]): string {
const manifest = encodeManifest(agents);
return `#!/bin/bash\nCOMPANY_NAME="${companyName}"\nAGENT_MANIFEST="${manifest}"`;
}
async function postImport(app: Parameters<typeof request>[0], body: unknown) {
return request(app, "POST", "/api/agents/import", JSON.stringify(body), {
"content-type": "application/json",
});
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("POST /api/agents/import", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
@@ -106,44 +77,33 @@ describe("POST /api/agents/import", () => {
mockCreateAgent.mockReset();
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
mockParseCompaniesShManifest.mockReturnValue({
companyName: "test-co",
agents: [{ name: "Test Agent", role: "executor" }],
envVars: [],
mockParseCompanyDirectory.mockReturnValue({
company: { name: "Directory Co", slug: "directory-co" },
agents: [{ name: "Dir Agent", skills: ["review"] }],
teams: [{ name: "Engineering" }],
projects: [],
tasks: [],
});
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "Test Agent", role: "executor" }],
result: {
created: ["Test Agent"],
skipped: [],
errors: [],
mockParseCompanyArchive.mockResolvedValue({
company: { name: "Archive Co", slug: "archive-co" },
agents: [{ name: "Archive Agent", skills: ["review"] }],
teams: [{ name: "Ops" }],
projects: [],
tasks: [],
});
mockParseSingleAgentManifest.mockReturnValue({
manifest: {
name: "YAML Agent",
title: "Chief Executive",
skills: ["review"],
instructionBody: "Instructions",
},
});
mockParseCompanyDirectory.mockReturnValue({
company: { name: "Directory Co" },
agents: [{ name: "Dir Agent", skills: ["executor"] }],
teams: [],
projects: [],
tasks: [],
skills: [],
});
mockParseCompanyArchive.mockResolvedValue({
company: { name: "Archive Co" },
agents: [{ name: "Archive Agent", skills: ["executor"] }],
teams: [],
projects: [],
tasks: [],
skills: [],
});
mockParseAgentManifest.mockReturnValue({
name: "YAML Agent",
title: "Chief Executive",
skills: ["review"],
instructionBody: "Instructions",
});
mockConvertAgentCompanies.mockReturnValue({
inputs: [{ name: "YAML Agent", role: "reviewer", title: "Chief Executive", metadata: { skills: ["review"] } }],
inputs: [{ name: "YAML Agent", role: "custom", title: "Chief Executive", metadata: { skills: ["review"] } }],
result: {
created: ["YAML Agent"],
skipped: [],
@@ -168,7 +128,7 @@ describe("POST /api/agents/import", () => {
expect((response.body as any).error).toContain("Provide one of");
});
it("imports agents via Mode 1 (agents array)", async () => {
it("imports agents via { agents } mode", async () => {
const response = await postImport(app, {
agents: [{ name: "Test Agent", skills: ["executor"] }],
});
@@ -178,9 +138,11 @@ describe("POST /api/agents/import", () => {
const body = response.body as any;
expect(body.created).toHaveLength(1);
expect(body.created[0].name).toBe("YAML Agent");
expect(body.companyName).toBe("Unknown");
expect(body.companySlug).toBeUndefined();
});
it("imports agents via Mode 2 (source directory)", async () => {
it("imports agents via { source } directory mode", async () => {
const sourceDir = join(testDir, "company");
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
@@ -190,61 +152,59 @@ describe("POST /api/agents/import", () => {
expect(response.status).toBe(200);
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
const body = response.body as any;
expect(body.companyName).toBe("Directory Co");
expect(body.companySlug).toBe("directory-co");
expect(body.created).toHaveLength(1);
});
it("imports agents via Mode 3 manifest string (YAML frontmatter)", async () => {
const response = await postImport(app, {
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
});
it("imports agents via { source } archive mode", async () => {
const archivePath = join(testDir, "company.tgz");
writeFileSync(archivePath, "archive");
const response = await postImport(app, { source: archivePath });
expect(response.status).toBe(200);
expect(mockParseAgentManifest).toHaveBeenCalled();
expect(mockParseCompaniesShManifest).not.toHaveBeenCalled();
});
it("falls back to legacy .sh parsing when YAML parse fails", async () => {
mockParseAgentManifest.mockImplementation(() => {
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
});
const response = await postImport(app, {
manifest: makeScript("fallback-co", [{ name: "Legacy Agent", role: "executor" }]),
});
expect(response.status).toBe(200);
expect(mockParseCompaniesShManifest).toHaveBeenCalledTimes(1);
expect(mockParseCompanyArchive).toHaveBeenCalledWith(archivePath);
const body = response.body as any;
expect(body.created).toHaveLength(1);
expect(body.companyName).toBe("Archive Co");
expect(body.companySlug).toBe("archive-co");
});
it("returns dry-run previews with agents array and does not create agents", async () => {
it("rejects non-directory source paths", async () => {
const filePath = join(testDir, "manifest.md");
writeFileSync(filePath, "---\nname: Agent\n---");
const response = await postImport(app, { source: filePath });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("directory");
});
it("imports agents via { manifest } AGENTS.md mode", async () => {
const response = await postImport(app, {
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
});
expect(response.status).toBe(200);
expect(mockParseSingleAgentManifest).toHaveBeenCalled();
});
it("returns dry-run preview and does not create agents", async () => {
const response = await postImport(app, {
manifest: "---\nname: YAML Agent\n---\nInstructions",
dryRun: true,
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.dryRun).toBe(true);
expect(body.created).toEqual(["YAML Agent"]);
expect(body.agents).toEqual([
expect.objectContaining({ name: "YAML Agent", role: "reviewer", title: "Chief Executive" }),
expect.objectContaining({ name: "YAML Agent", role: "custom", title: "Chief Executive" }),
]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("returns 400 for unsupported source paths", async () => {
const unsupportedPath = join(testDir, "manifest.json");
writeFileSync(unsupportedPath, "{}");
const response = await postImport(app, {
source: unsupportedPath,
});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("Unsupported source format");
});
it("honors skipExisting and returns skipped agents", async () => {
mockListAgents.mockResolvedValue([{ id: "agent-existing", name: "YAML Agent" }]);
mockConvertAgentCompanies.mockReturnValue({
@@ -266,4 +226,17 @@ describe("POST /api/agents/import", () => {
expect(body.skipped).toEqual(["YAML Agent"]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("returns 400 for parser errors", async () => {
mockParseSingleAgentManifest.mockImplementation(() => {
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");
});
const response = await postImport(app, {
manifest: "invalid",
});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("Missing YAML frontmatter");
});
});