feat(FN-1189): add Agent Companies import support

- Expand fn agent import to accept directories, .tar.gz/.tgz/.zip archives, single .md manifests, and legacy .sh manifests
- Update /api/agents/import to handle agents array, source path, or manifest string inputs with dry-run previews and legacy fallback parsing
- Refresh AgentImportModal and dashboard API typing for new preview metadata, directory uploads, and improved error handling
- Add coverage for CLI import paths, dashboard import routes, modal behavior, and assignment route timeout stability
- Add a minor @gsxdsm/fusion changeset documenting Agent Companies import support
This commit is contained in:
gsxdsm
2026-04-08 07:13:25 -07:00
parent 872369faed
commit cb24d6ccca
10 changed files with 803 additions and 277 deletions

View File

@@ -2043,9 +2043,10 @@ export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Ta
// ── Agent Import API ────────────────────────────────────────────────────────
/** Result of importing agents from a companies.sh manifest */
/** Result of importing agents from an Agent Companies source */
export interface AgentImportResult {
companyName: string;
companyName?: string;
agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>;
/** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */
created: string[] | Array<{ id: string; name: string }>;
skipped: string[];
@@ -2054,18 +2055,18 @@ export interface AgentImportResult {
}
/**
* Import agents from a companies.sh manifest via the API.
* Import agents from an Agent Companies source via the API.
* Uses dryRun for preview, then actual import.
*/
export function importAgents(
manifest: string,
input: { manifest?: string; source?: string; agents?: unknown[] },
options?: { dryRun?: boolean; skipExisting?: boolean },
projectId?: string,
): Promise<AgentImportResult> {
return api<AgentImportResult>(withProjectId("/agents/import", projectId), {
method: "POST",
body: JSON.stringify({
manifest,
...input,
dryRun: options?.dryRun ?? false,
skipExisting: options?.skipExisting ?? true,
}),

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AgentImportModal } from "./AgentImportModal";
interface MockResponse {
ok: boolean;
status: number;
body: unknown;
}
function mockFetchResponse({ ok, status, body }: MockResponse): Promise<Response> {
return Promise.resolve({
ok,
status,
json: async () => body,
} as Response);
}
describe("AgentImportModal", () => {
const onClose = vi.fn();
const onImported = vi.fn();
const originalFileReader = globalThis.FileReader;
beforeEach(() => {
vi.clearAllMocks();
class MockFileReader {
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
readAsText(file: Blob): void {
const content = (file as any).__content ?? "";
this.onload?.call(this as unknown as FileReader, {
target: { result: content },
} as ProgressEvent<FileReader>);
}
}
globalThis.FileReader = MockFileReader as unknown as typeof FileReader;
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.FileReader = originalFileReader;
});
it("renders the input step with file upload, directory button, and textarea", () => {
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
expect(screen.getByText("Import Agents")).toBeTruthy();
expect(screen.getByRole("button", { name: "Choose File" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Select Directory" })).toBeTruthy();
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
});
it("loads selected .md file content into the manifest textarea", async () => {
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
const fileInput = screen.getByLabelText("Upload agent manifest file") as HTMLInputElement;
const file = new File(["---\nname: CEO\n---\nLead"], "AGENTS.md", { type: "text/markdown" });
(file as any).__content = "---\nname: CEO\n---\nLead";
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
const textarea = screen.getByLabelText("Manifest content") as HTMLTextAreaElement;
expect(textarea.value).toContain("name: CEO");
});
});
it("shows parse preview using API-provided agents array", async () => {
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
dryRun: true,
companyName: "Acme Co",
agents: [
{
name: "CEO",
role: "executor",
title: "Chief Executive",
skills: ["review"],
},
],
created: ["CEO"],
skipped: [],
errors: [],
},
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "---\nname: CEO\n---\nLead" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByText("CEO")).toBeTruthy();
expect(screen.getByText(/executor/)).toBeTruthy();
expect(screen.getByText(/Chief Executive/)).toBeTruthy();
});
});
it("imports agents from preview step and shows result summary", async () => {
vi.mocked(globalThis.fetch)
.mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
dryRun: true,
companyName: "Acme Co",
agents: [{ name: "CEO", role: "executor", title: "Chief Executive", skills: ["review"] }],
created: ["CEO"],
skipped: [],
errors: [],
},
}))
.mockImplementationOnce(() => mockFetchResponse({
ok: true,
status: 200,
body: {
companyName: "Acme Co",
created: [{ id: "agent-1", name: "CEO" }],
skipped: [],
errors: [],
},
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "---\nname: CEO\n---\nLead" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /Import 1 Agent/i })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Import 1 Agent/i }));
await waitFor(() => {
expect(screen.getByText("Import Complete")).toBeTruthy();
expect(screen.getByText(/1 created/)).toBeTruthy();
});
expect(onImported).toHaveBeenCalledTimes(1);
});
it("shows API errors to the user", async () => {
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
ok: false,
status: 400,
body: { error: "No agents found" },
}));
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Manifest content"), {
target: { value: "invalid content" },
});
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByText("No agents found")).toBeTruthy();
});
});
});

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback } from "react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2 } from "lucide-react";
import { Upload, FileText, CheckCircle, AlertTriangle, X, Loader2, FolderOpen } from "lucide-react";
export interface AgentImportModalProps {
isOpen: boolean;
@@ -12,14 +12,13 @@ export interface AgentImportModalProps {
interface AgentPreview {
name: string;
role: string;
icon?: string;
title?: string;
model?: string;
skills?: string[];
}
/** Import result from the API */
interface ImportResult {
companyName: string;
companyName?: string;
created: Array<{ id: string; name: string }>;
skipped: string[];
errors: Array<{ name: string; error: string }>;
@@ -31,20 +30,23 @@ interface ApiErrorResponse {
}
type ModalStep = "input" | "preview" | "result";
type InputMethod = "paste" | "file" | "directory";
/**
* Modal for importing agents from a companies.sh manifest.
* Modal for importing agents from Agent Companies manifests.
*
* Supports two input methods:
* - File upload (.sh files)
* Supports three input methods:
* - File upload (.md/.txt/.sh files)
* - Directory upload (webkitdirectory)
* - Paste raw manifest content
*
* Flow: Input → Preview parsed agents → Import → Show results
*/
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
const [step, setStep] = useState<ModalStep>("input");
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
const [manifestContent, setManifestContent] = useState("");
const [companyName, setCompanyName] = useState("");
const [companyName, setCompanyName] = useState("Unknown");
const [agents, setAgents] = useState<AgentPreview[]>([]);
const [isParsing, setIsParsing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
@@ -52,11 +54,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const directoryInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("input");
setInputMethod("paste");
setManifestContent("");
setCompanyName("");
setCompanyName("Unknown");
setAgents([]);
setIsParsing(false);
setIsImporting(false);
@@ -77,6 +81,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const reader = new FileReader();
reader.onload = (ev) => {
const content = ev.target?.result as string;
setInputMethod("file");
setManifestContent(content);
setParseError(null);
};
@@ -89,6 +94,41 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
e.target.value = "";
}, []);
const handleDirectoryChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length === 0) return;
try {
const textFiles = files
.filter((file) => /\.(md|txt|sh)$/i.test(file.name))
.sort((a, b) => {
const aPath = a.webkitRelativePath || a.name;
const bPath = b.webkitRelativePath || b.name;
return aPath.localeCompare(bPath);
});
if (textFiles.length === 0) {
setParseError("Selected directory has no .md, .txt, or .sh files");
return;
}
const chunks: string[] = [];
for (const file of textFiles) {
const relativePath = file.webkitRelativePath || file.name;
const content = await file.text();
chunks.push(`--- FILE: ${relativePath} ---\n${content}`);
}
setInputMethod("directory");
setManifestContent(chunks.join("\n\n"));
setParseError(null);
} catch {
setParseError("Failed to read selected directory");
} finally {
e.target.value = "";
}
}, []);
/** Build the API URL with optional projectId */
function buildUrl(path: string): string {
if (!projectId) return `/api${path}`;
@@ -119,41 +159,18 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
}
const data = await res.json() as {
companyName: string;
companyName?: string;
agents?: AgentPreview[];
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
};
// Build agent previews from the created names in the dry-run result
// We need to extract more info from the manifest for the preview
let previewAgents: AgentPreview[] = [];
try {
// Parse the base64 manifest directly to get agent details for preview
const manifestMatch = manifestContent.match(/AGENT_MANIFEST=["'](.*)["']/);
if (manifestMatch) {
const decoded = atob(manifestMatch[1]);
const parsed = JSON.parse(decoded) as Array<Record<string, unknown>>;
previewAgents = parsed.map((a) => ({
name: String(a.name ?? ""),
role: String(a.role ?? "custom"),
icon: a.metadata && typeof a.metadata === "object"
? String((a.metadata as Record<string, unknown>).icon ?? "")
: undefined,
title: a.metadata && typeof a.metadata === "object"
? String((a.metadata as Record<string, unknown>).title ?? "")
: undefined,
model: a.config && typeof a.config === "object"
? String((a.config as Record<string, unknown>).model ?? "")
: undefined,
}));
}
} catch {
// Fallback: just show names from dry-run result
previewAgents = data.created.map((name) => ({ name, role: "custom" }));
}
const previewAgents = (data.agents && data.agents.length > 0)
? data.agents
: data.created.map((name) => ({ name, role: "custom" }));
setCompanyName(data.companyName);
setCompanyName(data.companyName ?? "Unknown");
setAgents(previewAgents);
setStep("preview");
} catch (err) {
@@ -210,7 +227,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
{step === "input" && (
<div className="agent-import-input">
<p className="agent-import-description">
Import agents from a companies.sh manifest file. Upload a <code>.sh</code> file or paste the manifest content directly.
Import agents from an Agent Companies package. Upload an AGENTS.md file, select a directory, or paste manifest content.
</p>
{/* File upload */}
@@ -218,10 +235,20 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<input
ref={fileInputRef}
type="file"
accept=".sh,.txt"
accept=".md,.txt,.sh"
onChange={handleFileChange}
className="agent-import-file-input"
aria-label="Upload companies.sh file"
aria-label="Upload agent manifest file"
/>
<input
ref={directoryInputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard but supported by Chromium browsers
webkitdirectory=""
multiple
onChange={handleDirectoryChange}
className="agent-import-file-input"
aria-label="Select directory"
/>
<button
type="button"
@@ -231,7 +258,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<Upload size={16} />
Choose File
</button>
<span className="agent-import-file-hint">.sh files supported</span>
<button
type="button"
className="btn agent-import-upload-btn"
onClick={() => directoryInputRef.current?.click()}
>
<FolderOpen size={16} />
Select Directory
</button>
<span className="agent-import-file-hint">.md, .txt, and .sh files supported</span>
</div>
{/* Or divider */}
@@ -242,13 +277,19 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
{/* Text area for paste */}
<textarea
className="agent-import-textarea"
placeholder={"#!/bin/bash\n# Agent Company Manifest\nCOMPANY_NAME=\"my-company\"\nAGENT_MANIFEST=\"base64...\""}
placeholder={"---\nname: Agent Name\ntitle: Agent Title\nskills:\n - review\n---\nAgent instructions go here..."}
value={manifestContent}
onChange={(e) => { setManifestContent(e.target.value); setParseError(null); }}
onChange={(e) => {
setInputMethod("paste");
setManifestContent(e.target.value);
setParseError(null);
}}
rows={8}
aria-label="Manifest content"
/>
<p className="agent-import-file-hint">Current input: {inputMethod}</p>
{parseError && (
<p className="agent-dialog-error">
<AlertTriangle size={14} />
@@ -275,15 +316,15 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<div className="agent-import-agent-list">
{agents.map((agent, idx) => (
<div key={idx} className="agent-import-agent-item">
<span className="agent-import-agent-icon">
{agent.icon || "🤖"}
</span>
<span className="agent-import-agent-icon">🤖</span>
<div className="agent-import-agent-details">
<span className="agent-import-agent-name">{agent.name}</span>
<span className="agent-import-agent-meta">
{agent.title && <span className="agent-import-agent-title">{agent.title} · </span>}
<span className="agent-import-agent-role">{agent.role}</span>
{agent.model && <span className="agent-import-agent-model"> · {agent.model}</span>}
{agent.skills && agent.skills.length > 0 && (
<span className="agent-import-agent-model"> · {agent.skills.join(", ")}</span>
)}
</span>
</div>
</div>
@@ -292,6 +333,13 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
) : (
<p className="agent-import-empty">No agents found in the manifest.</p>
)}
{importError && (
<p className="agent-dialog-error">
<AlertTriangle size={14} />
{importError}
</p>
)}
</div>
)}
@@ -303,7 +351,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
</div>
<h3 className="agent-import-result-title">Import Complete</h3>
<p className="agent-import-result-company">
From <strong>{importResult.companyName}</strong>
From <strong>{importResult.companyName ?? "Unknown"}</strong>
</p>
<div className="agent-import-result-stats">

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { request } from "../test-request.js";
// ── Mock @fusion/core for agent import ────────────────────────────────
@@ -10,6 +13,24 @@ 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 mockConvertAgentCompanies = vi.fn();
class MockCompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
}
class MockAgentCompaniesParseError extends Error {
constructor(message: string) {
super(message);
this.name = "AgentCompaniesParseError";
}
}
vi.mock("@fusion/core", () => {
return {
@@ -20,12 +41,12 @@ vi.mock("@fusion/core", () => {
},
parseCompaniesShManifest: (...args: unknown[]) => mockParseCompaniesShManifest(...args),
convertCompaniesShAgents: (...args: unknown[]) => mockConvertCompaniesShAgents(...args),
CompaniesShParseError: class CompaniesShParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CompaniesShParseError";
}
},
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
parseAgentManifest: (...args: unknown[]) => mockParseAgentManifest(...args),
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
CompaniesShParseError: MockCompaniesShParseError,
AgentCompaniesParseError: MockAgentCompaniesParseError,
};
});
@@ -33,11 +54,11 @@ vi.mock("@fusion/core", () => {
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-976-test";
return "/tmp/fn-1189-test";
}
getFusionDir(): string {
return "/tmp/fn-976-test/.fusion";
return "/tmp/fn-1189-test/.fusion";
}
getDatabase() {
@@ -74,13 +95,16 @@ async function postImport(app: Parameters<typeof request>[0], body: unknown) {
describe("POST /api/agents/import", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let testDir: string;
beforeEach(async () => {
// Reset mock implementations (don't reassign variables — class fields capture by reference)
vi.clearAllMocks();
testDir = mkdtempSync(join(tmpdir(), "kb-agent-import-route-"));
mockInit.mockResolvedValue(undefined);
mockListAgents.mockResolvedValue([]);
mockCreateAgent.mockReset();
mockCreateAgent.mockImplementation(async (input: any) => ({ id: `agent-${input.name}`, ...input }));
mockParseCompaniesShManifest.mockReturnValue({
companyName: "test-co",
@@ -96,6 +120,37 @@ describe("POST /api/agents/import", () => {
},
});
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"] } }],
result: {
created: ["YAML Agent"],
skipped: [],
errors: [],
},
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
@@ -103,152 +158,112 @@ describe("POST /api/agents/import", () => {
afterEach(() => {
vi.restoreAllMocks();
rmSync(testDir, { recursive: true, force: true });
});
it("returns 400 when manifest is missing", async () => {
it("returns 400 when no supported input mode is provided", async () => {
const response = await postImport(app, {});
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("manifest is required");
expect((response.body as any).error).toContain("Provide one of");
});
it("returns 400 when manifest is not a string or valid object", async () => {
const response = await postImport(app, { manifest: 12345 });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("manifest must be");
});
it("returns 400 on invalid manifest content", async () => {
const { CompaniesShParseError } = await import("@fusion/core");
mockParseCompaniesShManifest.mockImplementation(() => {
throw new CompaniesShParseError("Missing COMPANY_NAME variable in manifest");
it("imports agents via Mode 1 (agents array)", async () => {
const response = await postImport(app, {
agents: [{ name: "Test Agent", skills: ["executor"] }],
});
const response = await postImport(app, { manifest: "not a valid manifest" });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("Missing COMPANY_NAME");
});
it("returns 400 when manifest has no agents", async () => {
mockParseCompaniesShManifest.mockReturnValue({
companyName: "empty-co",
agents: [],
envVars: [],
});
const response = await postImport(app, { manifest: makeScript("empty-co", []) });
expect(response.status).toBe(400);
expect((response.body as any).error).toContain("No agents found");
});
it("imports agents successfully", async () => {
mockCreateAgent.mockResolvedValue({ id: "agent-1", name: "Test Agent" });
const response = await postImport(app, { manifest: makeScript("test-co", [{ name: "Test Agent", role: "executor" }]) });
expect(response.status).toBe(200);
expect(mockConvertAgentCompanies).toHaveBeenCalledTimes(1);
const body = response.body as any;
expect(body.companyName).toBe("test-co");
expect(body.created).toHaveLength(1);
expect(body.created[0].name).toBe("Test Agent");
expect(body.errors).toHaveLength(0);
expect(body.created[0].name).toBe("YAML Agent");
});
it("returns dry-run preview without creating agents", async () => {
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "Preview Agent", role: "executor" }],
result: {
created: ["Preview Agent"],
skipped: [],
errors: [],
},
it("imports agents via Mode 2 (source directory)", async () => {
const sourceDir = join(testDir, "company");
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
const response = await postImport(app, { source: sourceDir });
expect(response.status).toBe(200);
expect(mockParseCompanyDirectory).toHaveBeenCalledWith(sourceDir);
const body = response.body as any;
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",
});
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("preview-co", [{ name: "Preview Agent", role: "executor" }]),
manifest: makeScript("fallback-co", [{ name: "Legacy Agent", role: "executor" }]),
});
expect(response.status).toBe(200);
expect(mockParseCompaniesShManifest).toHaveBeenCalledTimes(1);
const body = response.body as any;
expect(body.created).toHaveLength(1);
});
it("returns dry-run previews with agents array and does not create agents", async () => {
const response = await postImport(app, {
manifest: "---\nname: YAML Agent\nskills:\n - review\n---\nInstructions",
dryRun: true,
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.dryRun).toBe(true);
expect(body.companyName).toBe("test-co");
expect(body.created).toContain("Preview Agent");
// Should NOT call createAgent in dry-run mode
expect(body.agents).toEqual([
expect.objectContaining({ name: "YAML Agent", role: "reviewer", title: "Chief Executive" }),
]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("skips existing agents when skipExisting is true", async () => {
mockListAgents.mockResolvedValue([{ id: "existing-1", name: "Existing Agent" }]);
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [{ name: "New Agent", role: "reviewer" }],
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({
inputs: [],
result: {
created: ["New Agent"],
skipped: ["Existing Agent"],
created: [],
skipped: ["YAML Agent"],
errors: [],
},
});
mockCreateAgent.mockResolvedValue({ id: "agent-2", name: "New Agent" });
const response = await postImport(app, {
manifest: makeScript("skip-co", [
{ name: "Existing Agent", role: "executor" },
{ name: "New Agent", role: "reviewer" },
]),
manifest: "---\nname: YAML Agent\n---\nInstructions",
skipExisting: true,
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.skipped).toContain("Existing Agent");
expect(mockCreateAgent).toHaveBeenCalledTimes(1);
});
it("reports per-agent creation errors", async () => {
mockConvertCompaniesShAgents.mockReturnValue({
inputs: [
{ name: "Good Agent", role: "executor" },
{ name: "Bad Agent", role: "reviewer" },
],
result: {
created: ["Good Agent", "Bad Agent"],
skipped: [],
errors: [],
},
});
mockCreateAgent
.mockResolvedValueOnce({ id: "agent-1", name: "Good Agent" })
.mockRejectedValueOnce(new Error("Database error"));
const response = await postImport(app, {
manifest: makeScript("mixed-co", [
{ name: "Good Agent", role: "executor" },
{ name: "Bad Agent", role: "reviewer" },
]),
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.created).toHaveLength(1);
expect(body.errors).toHaveLength(1);
expect(body.errors[0].name).toBe("Bad Agent");
expect(body.errors[0].error).toContain("Database error");
});
it("accepts pre-parsed manifest object with agents array", async () => {
const response = await postImport(app, {
manifest: {
companyName: "parsed-co",
agents: [{ name: "Parsed Agent", role: "executor" }],
envVars: [],
},
});
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.companyName).toBe("parsed-co");
expect(body.skipped).toEqual(["YAML Agent"]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
});

View File

@@ -2056,7 +2056,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: agentId });
expect(res.body.assignedAgentId).toBe(agentId);
});
}, 20000);
it("returns 404 when assigning to a non-existent agent", async () => {
const res = await REQUEST(
@@ -2070,7 +2070,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(404);
expect(res.body.error).toBe("Agent not found");
expect(store.updateTask).not.toHaveBeenCalled();
});
}, 20000);
it("unassigns a task when agentId is null", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -2090,7 +2090,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: null });
expect(res.body.assignedAgentId).toBeUndefined();
});
}, 20000);
it("returns tasks assigned to the specified agent", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
@@ -2103,7 +2103,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.status).toBe(200);
expect(res.body.map((task: { id: string }) => task.id)).toEqual(["FN-001"]);
});
}, 20000);
it("returns 404 for /api/agents/:id/tasks when agent does not exist", async () => {
const res = await GET(buildApp(), "/api/agents/agent-missing/tasks");

View File

@@ -7270,64 +7270,154 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/**
* POST /api/agents/import
* Import agents from a companies.sh manifest.
* Body: { manifest: string (raw script content) | { companyName, agents, envVars }, skipExisting?: boolean, dryRun?: boolean }
* Import agents from Agent Companies packages or legacy companies.sh manifests.
*
* Body modes (checked in order):
* - { agents: AgentManifest[], skipExisting?, dryRun? }
* - { source: string, skipExisting?, dryRun? }
* - { manifest: string, skipExisting?, dryRun? }
*/
router.post("/agents/import", async (req, res) => {
try {
const { manifest, skipExisting, dryRun } = req.body;
if (!manifest) {
res.status(400).json({ error: "manifest is required" });
return;
}
const { AgentStore, parseCompaniesShManifest, convertCompaniesShAgents, CompaniesShParseError } = await import("@fusion/core");
let parsed;
if (typeof manifest === "string") {
// Raw script content — parse it
try {
parsed = parseCompaniesShManifest(manifest);
} catch (err) {
if (err instanceof CompaniesShParseError) {
res.status(400).json({ error: err.message });
return;
}
throw err;
}
} else if (typeof manifest === "object" && Array.isArray((manifest as Record<string, unknown>).agents)) {
// Pre-parsed object
parsed = manifest as { companyName: string; agents: unknown[]; envVars?: unknown[] };
} else {
res.status(400).json({ error: "manifest must be a string (script content) or parsed object with agents array" });
return;
}
if (!parsed.agents || parsed.agents.length === 0) {
res.status(400).json({ error: "No agents found in manifest" });
return;
}
const { agents, source, manifest, skipExisting, dryRun } = req.body ?? {};
const {
AgentStore,
parseCompanyDirectory,
parseCompanyArchive,
parseAgentManifest,
convertAgentCompanies,
AgentCompaniesParseError,
parseCompaniesShManifest,
convertCompaniesShAgents,
CompaniesShParseError,
} = await import("@fusion/core");
const scopedStore = await getScopedStore(req);
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
// Get existing agent names for skip logic
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a: any) => a.name));
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
// Convert agents to AgentCreateInput
const { inputs, result } = convertCompaniesShAgents(
parsed.agents as any[],
skipExisting ? { skipExisting: [...existingNames] } : undefined,
);
let companyName: string | undefined;
let inputs: any[] = [];
let result: {
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
} = {
created: [],
skipped: [],
errors: [],
};
if (Array.isArray(agents)) {
const pkg = {
agents,
teams: [],
projects: [],
tasks: [],
skills: [],
};
({ inputs, result } = convertAgentCompanies(pkg as any, conversionOptions));
} else if (typeof source === "string" && source.trim()) {
const sourcePath = resolve(source);
if (!existsSync(sourcePath)) {
res.status(400).json({ error: `source does not exist: ${sourcePath}` });
return;
}
const isArchive =
sourcePath.endsWith(".tar.gz") || sourcePath.endsWith(".tgz") || sourcePath.endsWith(".zip");
let pkg;
if (nodeFs.statSync(sourcePath).isDirectory()) {
pkg = parseCompanyDirectory(sourcePath);
} else if (isArchive) {
pkg = await parseCompanyArchive(sourcePath);
} else {
res.status(400).json({ error: "Unsupported source format. Provide a directory or .tar.gz/.zip archive path." });
return;
}
companyName = pkg.company?.name;
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
} else if (typeof manifest === "string") {
try {
const segmentedMatches = [...manifest.matchAll(
/--- FILE:\s*([^\n]+)\s*---\n([\s\S]*?)(?=(?:\n--- FILE:\s*[^\n]+\s*---\n)|$)/g,
)];
if (segmentedMatches.length > 0) {
const parsedAgents = segmentedMatches
.map(([, relativePath, content]) => ({
relativePath: relativePath.trim(),
content,
}))
.filter(({ relativePath }) => relativePath.toLowerCase().endsWith("agents.md"))
.map(({ content }) => parseAgentManifest(content));
const pkg = {
agents: parsedAgents,
teams: [],
projects: [],
tasks: [],
skills: [],
};
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
} else {
const parsedAgentManifest = parseAgentManifest(manifest);
const pkg = {
agents: [parsedAgentManifest],
teams: [],
projects: [],
tasks: [],
skills: [],
};
({ inputs, result } = convertAgentCompanies(pkg, conversionOptions));
}
} catch (err) {
if (!(err instanceof AgentCompaniesParseError)) {
throw err;
}
try {
const parsedCompaniesSh = parseCompaniesShManifest(manifest);
companyName = parsedCompaniesSh.companyName;
({ inputs, result } = convertCompaniesShAgents(parsedCompaniesSh.agents as any[], conversionOptions));
} catch (fallbackErr) {
if (fallbackErr instanceof CompaniesShParseError) {
res.status(400).json({ error: fallbackErr.message });
return;
}
throw fallbackErr;
}
}
} else {
res.status(400).json({ error: "Provide one of: agents (array), source (path), or manifest (string)" });
return;
}
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
res.status(400).json({ error: "No agents found in manifest" });
return;
}
// Dry run: return preview without creating
if (dryRun) {
const agentPreview = inputs.map((input: any) => ({
name: input.name,
role: input.role,
title: typeof input.title === "string" ? input.title : undefined,
skills: Array.isArray(input.metadata?.skills)
? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
: undefined,
}));
res.json({
dryRun: true,
companyName: parsed.companyName,
companyName,
agents: agentPreview,
created: result.created,
skipped: result.skipped,
errors: result.errors,
@@ -7335,12 +7425,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
return;
}
// Create agents
const created: any[] = [];
const errors: Array<{ name: string; error: string }> = [...result.errors];
for (const input of inputs) {
// 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" });
continue;
@@ -7355,12 +7443,16 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
res.json({
companyName: parsed.companyName,
companyName,
created,
skipped: result.skipped,
errors,
});
} catch (err: any) {
if (err?.name === "AgentCompaniesParseError" || err?.name === "CompaniesShParseError") {
res.status(400).json({ error: err.message });
return;
}
res.status(500).json({ error: err.message });
}
});