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

@@ -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">