feat(FN-1968): add skill manifest previews to agent import

- Add SkillManifest support in core with parseSkillManifest and skills directory parsing/export wiring
- Extend /api/agents/import responses with dry-run skill previews and skillsCount metadata
- Show parsed package skills in AgentImportModal with dedicated preview UI and styles
- Add parser, API route, and modal tests covering skill manifest parsing and preview rendering
- Keep mission interview assertion generation explicit for milestone/slice fallbacks and update memory guidance for dashboard build resolution
This commit is contained in:
Fusion
2026-04-16 16:05:12 -07:00
committed by gsxdsm
parent 47f19676ae
commit fc33ea89ee
11 changed files with 259 additions and 10 deletions

View File

@@ -307,7 +307,7 @@ Dashboard SSE (`/api/events`) streams plugin lifecycle events as normalized `plu
- SQLite `ORDER BY timestamp DESC` alone can be nondeterministic when multiple rows share the same millisecond timestamp; add a stable tiebreaker (for example `rowid DESC`) when selecting a "latest" event.
- In `TaskCard.tsx`, `isInteractiveTarget` must check `target instanceof Element` (not `HTMLElement`) so SVG elements from lucide-react icons are correctly detected as interactive when inside buttons.
- If workspace tests fail resolving `@fusion/core` package exports from `packages/core/dist/index.js` (for example `No matching export ...` in CLI/TUI/package-level tests after adding a new core export), run `pnpm --filter @fusion/core build` before rerunning the suite so ignored `dist/` exports are refreshed.
- If CLI tests fail resolving `@fusion/dashboard` (for example `Could not resolve "@fusion/dashboard"` in `build-exe` or command tests), build dashboard first with `pnpm --filter @fusion/dashboard build` so `packages/dashboard/dist/index.js` exists.
- If CLI tests/build-exe tests fail with `Could not resolve "@fusion/dashboard"` (or Vite reports missing `@fusion/dashboard` entry), build the dashboard package first (`pnpm --filter @fusion/dashboard build`) so `packages/dashboard/dist/index.js` exists for workspace consumers.
- QuickEntryBox control test IDs are reused in `ListView` integration tests; when control layout changes (for example nested menu → inline buttons), update both `QuickEntryBox.test.tsx` and `ListView.test.tsx` together to avoid cascading failures.
- When `InlineCreateCard` layout changes, also check `Column.test.tsx` and `board-mobile.test.tsx` for references to moved/removed test IDs like `inline-create-description-actions`.
- When adding portal-based dropdown menus to QuickEntryBox, tests may fail in isolation but pass when run together (test isolation issues). This is because tests share DOM state across describe blocks. Always verify new dropdown tests pass both in isolation (`--testNamePattern`) and when run together.

View File

@@ -17,6 +17,7 @@ import {
parseCompanyManifest,
parseProjectManifest,
parseSingleAgentManifest,
parseSkillManifest,
parseTaskManifest,
parseTeamManifest,
parseYamlFrontmatter,
@@ -185,6 +186,24 @@ schedule:
expect(manifest.assignee).toBe("./agents/ceo/AGENTS.md");
expect(manifest.schedule?.timezone).toBe("America/New_York");
});
it("parses SKILL.md with instruction body", () => {
const manifest = parseSkillManifest(`---
name: review
schema: agentcompanies/v1
kind: skill
---
# review
Add skill instructions here.`);
expect(manifest).toEqual({
name: "review",
schema: "agentcompanies/v1",
kind: "skill",
instructionBody: "# review\n\nAdd skill instructions here.",
});
});
});
describe("directory parsing", () => {
@@ -252,6 +271,43 @@ name: Solo Agent
expect(pkg.teams).toEqual([]);
});
it("parses skills from skills subdirectories", () => {
const root = createTempDir();
writeTextFile(
join(root, "skills", "review", "SKILL.md"),
`---
name: review
kind: skill
---
# review`,
);
writeTextFile(
join(root, "skills", "strategy", "SKILL.md"),
`---
name: strategy
kind: skill
---
# strategy`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.skills).toHaveLength(2);
expect(pkg.skills?.map((skill) => skill.name)).toEqual(["review", "strategy"]);
});
it("returns empty skills when skills directory is absent", () => {
const root = createTempDir();
writeTextFile(
join(root, "agents", "solo", "AGENTS.md"),
`---
name: Solo Agent
---`,
);
const pkg = parseCompanyDirectory(root);
expect(pkg.skills).toEqual([]);
});
it("parses empty directory", () => {
const root = createTempDir();
const pkg = parseCompanyDirectory(root);
@@ -261,6 +317,7 @@ name: Solo Agent
teams: [],
projects: [],
tasks: [],
skills: [],
});
});

View File

@@ -18,6 +18,7 @@ import type {
AgentManifest,
CompanyManifest,
ProjectManifest,
SkillManifest,
TaskManifest,
TeamManifest,
} from "./agent-companies-types.js";
@@ -344,6 +345,15 @@ export function parseTaskManifest(content: string): TaskManifest {
return parseTypedManifest<TaskManifest>(content, "task");
}
export function parseSkillManifest(content: string): SkillManifest {
const { frontmatter, body } = parseYamlFrontmatter(content);
requireName(frontmatter, "skill");
return {
...(frontmatter as unknown as SkillManifest),
instructionBody: body,
};
}
function parseManifestFile<T>(filePath: string, parser: (content: string) => T): T {
try {
return parser(readFileSync(filePath, "utf-8"));
@@ -443,6 +453,7 @@ export function parseCompanyDirectory(dirPath: string): AgentCompaniesPackage {
parseProjectManifest,
),
tasks: parseManifestSubdirectories(resolvedPath, "tasks", "TASK.md", parseTaskManifest),
skills: parseManifestSubdirectories(resolvedPath, "skills", "SKILL.md", parseSkillManifest),
};
}

View File

@@ -70,12 +70,17 @@ export interface TaskManifest extends AgentCompaniesFrontmatter {
};
}
export interface SkillManifest extends AgentCompaniesFrontmatter {
instructionBody?: string;
}
export interface AgentCompaniesPackage {
company?: CompanyManifest;
agents: AgentManifest[];
teams: TeamManifest[];
projects: ProjectManifest[];
tasks: TaskManifest[];
skills?: SkillManifest[];
}
export interface AgentCompaniesImportResult {

View File

@@ -482,6 +482,7 @@ export type {
AgentManifest,
ProjectManifest,
TaskManifest,
SkillManifest,
SourceReference,
} from "./agent-companies-types.js";
@@ -495,6 +496,7 @@ export {
parseSingleAgentManifest,
parseProjectManifest,
parseTaskManifest,
parseSkillManifest,
parseCompanyDirectory,
parseCompanyArchive,
mapRoleToCapability,

View File

@@ -20,6 +20,11 @@ interface AgentPreview {
skills?: string[];
}
interface SkillPreview {
name: string;
description?: string;
}
/** Import result from the API */
interface ImportResult {
companyName?: string;
@@ -118,6 +123,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
const [companyName, setCompanyName] = useState("Unknown");
const [agents, setAgents] = useState<AgentPreview[]>([]);
const [skills, setSkills] = useState<SkillPreview[]>([]);
const [isParsing, setIsParsing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
@@ -193,6 +199,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
setDirectoryAgents([]);
setCompanyName("Unknown");
setAgents([]);
setSkills([]);
setIsParsing(false);
setIsImporting(false);
setParseError(null);
@@ -317,6 +324,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const data = await res.json() as {
companyName?: string;
agents?: AgentPreview[];
skills?: SkillPreview[];
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
@@ -325,9 +333,11 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const previewAgents = (data.agents && data.agents.length > 0)
? data.agents
: data.created.map((name) => ({ name, role: "custom" }));
const previewSkills = Array.isArray(data.skills) ? data.skills : [];
setCompanyName(data.companyName ?? "Unknown");
setAgents(previewAgents);
setSkills(previewSkills);
setStep("preview");
} catch (err) {
setParseError(err instanceof Error ? err.message : "Failed to parse manifest");
@@ -628,6 +638,28 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
<p className="agent-import-empty">No agents found in the manifest.</p>
)}
{skills.length > 0 && (
<div className="agent-import-skills-section">
<div className="agent-import-count">
<FileText size={14} />
<span>{skills.length} skill{skills.length !== 1 ? "s" : ""} found</span>
</div>
<div className="agent-import-skill-list">
{skills.map((skill, idx) => (
<div key={`${skill.name}-${idx}`} className="agent-import-skill-item">
<span className="agent-import-skill-icon">⚡</span>
<div className="agent-import-skill-details">
<span className="agent-import-skill-name">{skill.name}</span>
{skill.description && (
<span className="agent-import-skill-description">{skill.description}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{importError && (
<p className="agent-dialog-error">
<AlertTriangle size={14} />

View File

@@ -52,6 +52,10 @@ describe("AgentImportModal", () => {
{ name: "Reviewer", role: "reviewer", title: "Code Reviewer", skills: ["review"] },
{ name: "Planner", role: "triage", title: "Planner" },
],
skills: [
{ name: "review", description: "Review implementation details" },
{ name: "strategy" },
],
created: ["Reviewer", "Planner"],
skipped: [],
errors: [],
@@ -130,6 +134,10 @@ describe("AgentImportModal", () => {
expect(screen.getByText("Planner")).toBeInTheDocument();
expect(screen.getByText(/reviewer/)).toBeInTheDocument();
expect(screen.getByText(/triage/)).toBeInTheDocument();
expect(screen.getByText("2 skills found")).toBeInTheDocument();
expect(screen.getByText("review")).toBeInTheDocument();
expect(screen.getByText("strategy")).toBeInTheDocument();
expect(screen.getByText("Review implementation details")).toBeInTheDocument();
});
it("Back button returns to input step", async () => {
@@ -143,6 +151,34 @@ describe("AgentImportModal", () => {
expect(screen.queryByText("2 agents found")).not.toBeInTheDocument();
});
it("does not render skills section when no package skills are returned", async () => {
renderModal(true);
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
await mockResponse({
ok: true,
body: {
companyName: "Acme AI",
agents: [{ name: "Reviewer", role: "reviewer" }],
created: ["Reviewer"],
skipped: [],
errors: [],
dryRun: true,
},
}),
);
const user = userEvent.setup();
await user.type(screen.getByLabelText("Manifest content"), "---\nname: Reviewer\nrole: reviewer\n---");
await user.click(screen.getByRole("button", { name: "Preview" }));
await waitFor(() => {
expect(screen.getByText("1 agent found")).toBeInTheDocument();
});
expect(screen.queryByText(/skills found/)).not.toBeInTheDocument();
});
it("handleImport posts live import request and transitions to result step", async () => {
renderModal(true);

View File

@@ -27877,6 +27877,49 @@ html .column.drag-over * {
color: var(--text-muted);
}
/* === Agent Import Skills === */
.agent-import-skills-section {
margin-top: var(--space-lg);
}
.agent-import-skill-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: calc(var(--space-2xl) * 6);
overflow-y: auto;
}
.agent-import-skill-item {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid var(--border);
}
.agent-import-skill-icon {
flex-shrink: 0;
}
.agent-import-skill-details {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.agent-import-skill-name {
font-weight: 500;
color: var(--text);
}
.agent-import-skill-description {
color: var(--text-muted);
}
.agent-import-empty {
color: var(--text-muted);
font-size: 13px;

View File

@@ -87,6 +87,7 @@ describe("POST /api/agents/import", () => {
teams: [{ name: "Engineering" }],
projects: [],
tasks: [],
skills: [{ name: "review" }, { name: "strategy" }],
});
mockParseCompanyArchive.mockResolvedValue({
@@ -95,6 +96,7 @@ describe("POST /api/agents/import", () => {
teams: [{ name: "Ops" }],
projects: [],
tasks: [],
skills: [{ name: "review" }, { name: "strategy" }],
});
mockParseSingleAgentManifest.mockReturnValue({
@@ -149,6 +151,7 @@ describe("POST /api/agents/import", () => {
expect(body.created[0].name).toBe("YAML Agent");
expect(body.companyName).toBe("Unknown");
expect(body.companySlug).toBeUndefined();
expect(body.skillsCount).toBe(0);
});
it("imports agents via { source } directory mode", async () => {
@@ -164,6 +167,7 @@ describe("POST /api/agents/import", () => {
expect(body.companyName).toBe("Directory Co");
expect(body.companySlug).toBe("directory-co");
expect(body.created).toHaveLength(1);
expect(body.skillsCount).toBe(2);
});
it("imports agents via { source } archive mode", async () => {
@@ -177,6 +181,7 @@ describe("POST /api/agents/import", () => {
const body = response.body as any;
expect(body.companyName).toBe("Archive Co");
expect(body.companySlug).toBe("archive-co");
expect(body.skillsCount).toBe(2);
});
it("creates hierarchical agents with resolved parent ids", async () => {
@@ -302,6 +307,7 @@ describe("POST /api/agents/import", () => {
expect(body.agents).toEqual([
expect.objectContaining({ name: "YAML Agent", role: "custom", title: "Chief Executive" }),
]);
expect(body.skills).toEqual([]);
expect(mockCreateAgent).not.toHaveBeenCalled();
});
@@ -327,6 +333,53 @@ describe("POST /api/agents/import", () => {
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("dry-run preview includes skills from company package", async () => {
const sourceDir = join(testDir, "skills-company");
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
mockParseCompanyDirectory.mockReturnValue({
company: { name: "Directory Co", slug: "directory-co" },
agents: [{ name: "Dir Agent", skills: ["review"] }],
teams: [{ name: "Engineering" }],
projects: [],
tasks: [],
skills: [
{ name: "review", description: "Review implementation details" },
{ name: "strategy" },
],
});
const response = await postImport(app, { source: sourceDir, dryRun: true });
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.skills).toEqual([
{ name: "review", description: "Review implementation details" },
{ name: "strategy" },
]);
});
it("dry-run preview returns empty skills when package has no skills", async () => {
const sourceDir = join(testDir, "no-skills-company");
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
mockParseCompanyDirectory.mockReturnValue({
company: { name: "Directory Co", slug: "directory-co" },
agents: [{ name: "Dir Agent", skills: ["review"] }],
teams: [{ name: "Engineering" }],
projects: [],
tasks: [],
});
const response = await postImport(app, { source: sourceDir, dryRun: true });
expect(response.status).toBe(200);
const body = response.body as any;
expect(body.skills).toEqual([]);
});
it("returns 400 for parser errors", async () => {
mockParseSingleAgentManifest.mockImplementation(() => {
throw new MockAgentCompaniesParseError("Missing YAML frontmatter delimiters (---)");

View File

@@ -735,7 +735,7 @@ export function createMissionRouter(
missionStore.updateMission(mission.id, { interviewState: "completed" as InterviewState });
// Create milestones, slices, and features with verification in dedicated fields.
// Auto-generate contract assertions for milestone, slice, and feature levels.
// Auto-generate contract assertions at milestone, slice, and feature levels.
for (const milestoneData of (summary.milestones ?? [])) {
// Use dedicated verification field instead of concatenating into description
const milestone = missionStore.addMilestone(mission.id, {
@@ -745,12 +745,12 @@ export function createMissionRouter(
});
// Milestone-level assertion remains on the milestone even when it has no slices.
const milestoneAssertionText = milestoneData.verification
|| milestoneData.description
|| `Verify milestone completion: ${milestoneData.title}`;
missionStore.addContractAssertion(milestone.id, {
title: `Milestone: ${milestoneData.title}`,
assertion:
milestoneData.verification
|| milestoneData.description
|| `Verify milestone completion: ${milestoneData.title}`,
assertion: milestoneAssertionText,
status: "pending",
});
@@ -763,12 +763,12 @@ export function createMissionRouter(
});
// Slice-level assertion for explicit verification criteria.
const sliceAssertionText = sliceData.verification
|| sliceData.description
|| `Verify slice completion: ${sliceData.title}`;
missionStore.addContractAssertion(milestone.id, {
title: `Slice: ${sliceData.title}`,
assertion:
sliceData.verification
|| sliceData.description
|| `Verify slice completion: ${sliceData.title}`,
assertion: sliceAssertionText,
status: "pending",
});

View File

@@ -10705,6 +10705,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
teams: unknown[];
projects: unknown[];
tasks: unknown[];
skills?: unknown[];
};
if (Array.isArray(agents)) {
@@ -10977,11 +10978,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
: undefined,
}));
const skillPreview = (pkg.skills ?? [])
.filter((skill): skill is Record<string, unknown> => typeof skill === "object" && skill !== null)
.map((skill) => ({
name: typeof skill.name === "string" && skill.name.length > 0 ? skill.name : "Unnamed Skill",
description: typeof skill.description === "string" ? skill.description : undefined,
}));
res.json({
dryRun: true,
companyName,
...(companySlug ? { companySlug } : {}),
agents: agentPreview,
skills: skillPreview,
created: result.created,
skipped: result.skipped,
errors: result.errors,
@@ -11036,6 +11045,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
created,
skipped: result.skipped,
errors,
skillsCount: (pkg.skills ?? []).length,
});
} catch (err: unknown) {
if (err instanceof ApiError) {