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:
@@ -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,
|
||||
}),
|
||||
|
||||
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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user