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:
172
packages/dashboard/app/components/AgentImportModal.test.tsx
Normal file
172
packages/dashboard/app/components/AgentImportModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user