feat(FN-1794): merge fusion/fn-1794

This commit is contained in:
gsxdsm
2026-04-15 04:30:56 -07:00
parent 592ba65826
commit 1eb71a49fe
8 changed files with 765 additions and 1 deletions

View File

@@ -12,6 +12,7 @@ import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
import { getAgentHealthStatus } from "../utils/agentHealth";
import { SkillMultiselect } from "./SkillMultiselect";
/**
* Simple className utility - joins class names conditionally
@@ -653,6 +654,20 @@ function DashboardTab({
<span className="info-label">Role</span>
<span className="info-value">{agent.role}</span>
</div>
<div className="info-item">
<span className="info-label">Skills</span>
<span className="info-value">
{Array.isArray(agent.metadata?.skills) && (agent.metadata.skills as string[]).length > 0 ? (
<div className="skill-badge-row">
{(agent.metadata.skills as string[]).map((skillId: string) => (
<span key={skillId} className="skill-badge">{skillId}</span>
))}
</div>
) : (
"—"
)}
</span>
</div>
<div className="info-item">
<span className="info-label">State</span>
<span className="info-value">
@@ -2285,6 +2300,11 @@ function ConfigTab({
const [bundleExternalPath, setBundleExternalPath] = useState(agent.bundleConfig?.externalPath ?? "");
const [bundleFiles, setBundleFiles] = useState<string[]>(agent.bundleConfig?.files ?? []);
// Skills state initialized from agent.metadata.skills
const [selectedSkills, setSelectedSkills] = useState<string[]>(
Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : []
);
// Budget status for progress bar display
const [budgetStatus, setBudgetStatus] = useState<AgentBudgetStatus | null>(null);
const [isResettingBudget, setIsResettingBudget] = useState(false);
@@ -2360,6 +2380,10 @@ function ConfigTab({
: "";
if (currentThreshold !== persistedThreshold) return true;
// Check skills
const persistedSkills = Array.isArray(agent.metadata?.skills) ? agent.metadata.skills as string[] : [];
if (JSON.stringify(selectedSkills) !== JSON.stringify(persistedSkills)) return true;
return false;
})();
@@ -2488,6 +2512,13 @@ function ConfigTab({
}
}
// Handle skills in metadata
if (selectedSkills.length > 0) {
newMetadata.skills = selectedSkills;
} else {
delete newMetadata.skills;
}
// Build the runtimeConfig payload — only include non-empty values
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
@@ -2646,6 +2677,25 @@ function ConfigTab({
</div>
</div>
<div className="config-section">
<h3>Skills</h3>
<p className="config-description">
Assign skills to this agent for specialized behavior.
</p>
<div className="config-fields">
<div className="config-field">
<SkillMultiselect
id="agent-skills"
label="Skills"
value={selectedSkills}
onChange={setSelectedSkills}
projectId={projectId}
/>
</div>
</div>
</div>
<div className="config-section">
<h3>Heartbeat Settings</h3>
<p className="config-description">

View File

@@ -47,6 +47,7 @@ function AgentTreeNode({
getChildCount,
getHealthStatus,
getRoleIcon,
getSkillBadges,
}: {
node: AgentNode;
onSelect: (id: string) => void;
@@ -55,6 +56,7 @@ function AgentTreeNode({
getChildCount: (id: string) => number;
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
getRoleIcon: (role: AgentCapability) => string;
getSkillBadges: (agent: Agent) => string[];
}) {
const { agent, children, depth } = node;
const childCount = getChildCount(agent.id);
@@ -104,6 +106,16 @@ function AgentTreeNode({
{childCount > 0 && (
<span className="agent-tree__count text-secondary">({childCount})</span>
)}
{/* Tree view: up to 1 skill badge */}
{(() => {
const skills = getSkillBadges(agent);
if (skills.length === 0) return null;
return (
<span className="agent-tree__skill" title={skills.join(", ")}>
{skills[0]}{skills.length > 1 && ` +${skills.length - 1}`}
</span>
);
})()}
</div>
</div>
{expanded && children.length > 0 && (
@@ -118,6 +130,7 @@ function AgentTreeNode({
getChildCount={getChildCount}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
/>
))}
</div>
@@ -131,11 +144,13 @@ function OrgChartNode({
onSelect,
getHealthStatus,
getRoleIcon,
getSkillBadges,
}: {
node: OrgTreeNode;
onSelect: (id: string) => void;
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
getRoleIcon: (role: AgentCapability) => string;
getSkillBadges: (agent: Agent) => string[];
}) {
const { agent, children } = node;
const health = getHealthStatus(agent);
@@ -169,6 +184,21 @@ function OrgChartNode({
{health.icon}
<span className="text-secondary">{health.label}</span>
</span>
{/* Org chart: up to 2 skill badges */}
{(() => {
const skills = getSkillBadges(agent);
if (skills.length === 0) return null;
const displaySkills = skills.slice(0, 2);
const extraCount = skills.length - 2;
return (
<>
{displaySkills.map((skillId) => (
<span key={skillId} className="org-chart-node__skill">{skillId}</span>
))}
{extraCount > 0 && <span className="org-chart-node__skill">+{extraCount}</span>}
</>
);
})()}
</div>
</div>
{children.length > 0 && (
@@ -180,6 +210,7 @@ function OrgChartNode({
onSelect={onSelect}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
/>
))}
</div>
@@ -406,6 +437,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "🤖";
/** Get skill badges from agent metadata */
const getSkillBadges = (agent: Agent): string[] => {
if (Array.isArray(agent.metadata?.skills)) {
return agent.metadata.skills as string[];
}
return [];
};
// Use centralized health status utility for consistent labels across all views
const getHealthStatus = (agent: Agent): { label: string; icon: JSX.Element; color: string } => {
return getAgentHealthStatus(agent);
@@ -547,6 +586,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
getChildCount={(id) => hierarchy.getChildren(id).length}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
/>
))
)}
@@ -572,6 +612,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
onSelect={setSelectedAgentId}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
/>
))
)}
@@ -618,6 +659,21 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
{agent.name}
</div>
<div className="agent-board-id">{agent.id}</div>
{/* Board view: up to 1 skill badge */}
{(() => {
const skills = getSkillBadges(agent);
if (skills.length === 0) return null;
const displaySkills = skills.slice(0, 1);
const extraCount = skills.length - 1;
return (
<div className="agent-board-skills">
{displaySkills.map((skillId) => (
<span key={skillId} className="skill-badge-sm">{skillId}</span>
))}
{extraCount > 0 && <span className="skill-badge-sm skill-badge-extra">+{extraCount}</span>}
</div>
);
})()}
</div>
<div className="agent-board-actions">
{agent.state === "idle" && (
@@ -817,6 +873,21 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<span className="badge text-secondary">
{getRoleLabel(agent.role)}
</span>
{/* List view: up to 2 skill badges */}
{(() => {
const skills = getSkillBadges(agent);
if (skills.length === 0) return null;
const displaySkills = skills.slice(0, 2);
const extraCount = skills.length - 2;
return (
<>
{displaySkills.map((skillId) => (
<span key={skillId} className="badge badge-skill">{skillId}</span>
))}
{extraCount > 0 && <span className="badge badge-skill">+{extraCount}</span>}
</>
);
})()}
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentGenerationModal } from "./AgentGenerationModal";
import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
import { SkillMultiselect } from "./SkillMultiselect";
export interface NewAgentDialogProps {
isOpen: boolean;
@@ -51,6 +52,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
maxTurns: 1000,
});
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
@@ -169,6 +171,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setMemory("");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 1000 });
setSelectedPresetId(null);
setSelectedSkills([]);
setError(null);
setIsGenerationModalOpen(false);
onClose();
@@ -194,6 +197,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
...(soul.trim() ? { soul: soul.trim() } : {}),
...(memory.trim() ? { memory: memory.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
...(selectedSkills.length > 0 ? { metadata: { skills: selectedSkills } } : {}),
}, projectId);
handleClose();
onCreated();
@@ -419,6 +423,18 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
/>
</div>
<div className="agent-dialog-field">
<SkillMultiselect
id="agent-skills"
label="Skills"
value={selectedSkills}
onChange={setSelectedSkills}
projectId={projectId}
/>
<p className="agent-dialog-optional agent-dialog-skills-hint">
Optional skills to assign to this agent
</p>
</div>
</div>
)}
@@ -497,6 +513,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
<span className="agent-dialog-summary-row-label">Max Turns</span>
<span>{runtimeConfig.maxTurns}</span>
</div>
{selectedSkills.length > 0 && (
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">Skills</span>
<span>{selectedSkills.length} skill{selectedSkills.length !== 1 ? "s" : ""} selected</span>
</div>
)}
</div>
{error && (
<p className="agent-dialog-error">{error}</p>

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from "react";
import { fetchDiscoveredSkills } from "../api";
import type { DiscoveredSkill } from "../api";
export interface SkillMultiselectProps {
/** Currently selected skill IDs */
value: string[];
/** Called when the selection changes */
onChange: (skills: string[]) => void;
/** Project context for API calls */
projectId?: string;
/** Disable the control */
disabled?: boolean;
/** HTML id for accessibility */
id?: string;
/** Label text shown above the component */
label?: string;
}
/**
* Reusable skill multiselect component.
* Shows selected skills as removable chips and a dropdown to add more skills.
*/
export function SkillMultiselect({
value,
onChange,
projectId,
disabled = false,
id,
label = "Skills",
}: SkillMultiselectProps) {
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetchDiscoveredSkills(projectId)
.then((discovered) => {
if (!cancelled) {
setSkills(discovered);
}
})
.catch(() => {
if (!cancelled) {
setSkills([]);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const handleAddSkill = (skillId: string) => {
if (!value.includes(skillId)) {
onChange([...value, skillId]);
}
};
const handleRemoveSkill = (skillId: string) => {
onChange(value.filter((id) => id !== skillId));
};
// Get skill names for display
const getSkillName = (skillId: string): string => {
const skill = skills.find((s) => s.id === skillId);
return skill?.name ?? skillId;
};
// Skills available to add (not yet selected)
const availableSkills = skills.filter((s) => !value.includes(s.id));
return (
<div className="skill-multiselect" data-testid="skill-multiselect">
{label && (
<label
htmlFor={id ? `${id}-select` : undefined}
className="skill-multiselect-label"
>
{label}
</label>
)}
{/* Selected skill chips */}
{value.length > 0 && (
<div className="skill-multiselect-chips" data-testid="skill-chips">
{value.map((skillId) => (
<span key={skillId} className="skill-chip" data-testid={`skill-chip-${skillId}`}>
<span className="skill-chip-name">{getSkillName(skillId)}</span>
<button
type="button"
className="skill-chip-remove"
onClick={() => handleRemoveSkill(skillId)}
disabled={disabled}
aria-label={`Remove ${getSkillName(skillId)}`}
data-testid={`remove-skill-${skillId}`}
>
×
</button>
</span>
))}
</div>
)}
{/* Dropdown to add skills */}
<div className="skill-multiselect-add">
{isLoading ? (
<span className="skill-multiselect-loading" data-testid="skills-loading">
Loading skills
</span>
) : availableSkills.length === 0 ? (
<span className="skill-multiselect-empty" data-testid="skills-empty">
{value.length === 0 ? "No skills discovered" : "All skills selected"}
</span>
) : (
<select
id={id ? `${id}-select` : undefined}
className="select skill-multiselect-dropdown"
value=""
onChange={(e) => {
if (e.target.value) {
handleAddSkill(e.target.value);
// Reset to placeholder
e.target.value = "";
}
}}
disabled={disabled}
data-testid="skill-dropdown"
>
<option value="">Add a skill</option>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.id}>
{skill.name}
</option>
))}
</select>
)}
</div>
</div>
);
}

View File

@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
resetAgentBudget: vi.fn(),
fetchWorkspaceFileContent: vi.fn(),
saveWorkspaceFileContent: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -36,7 +37,18 @@ vi.mock("../AgentLogViewer", () => ({
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../../api";
// Mock SkillMultiselect
vi.mock("../SkillMultiselect", () => ({
SkillMultiselect: ({ value, onChange, id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => (
<div data-testid="skill-multiselect">
<span data-testid="skill-multiselect-value">{JSON.stringify(value)}</span>
<button data-testid="add-skill-test" onClick={() => onChange([...value, "test-skill"])}>Add Test Skill</button>
<button data-testid="remove-skill-test" onClick={() => onChange(value.filter(s => s !== "test-skill"))}>Remove Test Skill</button>
</div>
),
}));
import { fetchAgent, updateAgent, updateAgentState, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills } from "../../api";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockUpdateAgent = vi.mocked(updateAgent);
@@ -52,6 +64,12 @@ const mockResetAgentBudget = vi.mocked(resetAgentBudget);
const mockUpdateAgentInstructions = vi.mocked(updateAgentInstructions);
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
const mockSaveWorkspaceFileContent = vi.mocked(saveWorkspaceFileContent);
const mockFetchDiscoveredSkills = vi.mocked(fetchDiscoveredSkills);
const MOCK_SKILLS = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
{ id: "skill-2", name: "Skill Two", path: "/path/skill-2", relativePath: "skills/skill-2", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
];
describe("AgentDetailView", () => {
const createMockAgent = (overrides: Partial<{
@@ -124,6 +142,8 @@ describe("AgentDetailView", () => {
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockUpdateAgentInstructions.mockResolvedValue({} as any);
// Default: return skills
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS);
});
it("shows loading state initially", () => {
@@ -2418,4 +2438,189 @@ describe("AgentDetailView", () => {
});
});
});
// ── Skills ─────────────────────────────────────────────────────────────────
describe("Skills", () => {
it("renders skill badges in Dashboard tab when agent has skills", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1", "skill-2"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getAllByText("skill-1")).toBeTruthy();
expect(screen.getAllByText("skill-2")).toBeTruthy();
});
});
it("shows dash when agent has no skills in Dashboard tab", async () => {
const agentWithNoSkills = createMockAgent({
metadata: {},
});
mockFetchAgent.mockResolvedValue(agentWithNoSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Skills")).toBeInTheDocument();
// Should show dash when no skills
const skillsLabel = screen.getByText("Skills");
const parent = skillsLabel.closest(".info-item");
expect(parent?.textContent).toContain("—");
});
});
it("shows SkillMultiselect in Config tab", async () => {
const user = userEvent.setup();
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
// Navigate to Settings tab
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Should show pre-selected skill
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
});
it("pre-fills skills from agent metadata in Config tab", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1", "skill-2"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Should have both skills pre-selected
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-1");
expect(screen.getByTestId("skill-multiselect-value").textContent).toContain("skill-2");
});
it("includes skills in metadata when saving Config tab", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: ["skill-1"] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
mockUpdateAgent.mockResolvedValue(createMockAgent({ metadata: { skills: ["skill-1", "new-skill"] } }) as any);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Add a skill
await user.click(screen.getByTestId("add-skill-test"));
// Save settings
await user.click(screen.getByText("Save Settings"));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
metadata: expect.objectContaining({
skills: ["skill-1", "test-skill"],
}),
}),
undefined,
);
});
});
it("enables Save Settings when skills change", async () => {
const agentWithSkills = createMockAgent({
metadata: { skills: [] },
});
mockFetchAgent.mockResolvedValue(agentWithSkills);
const user = userEvent.setup();
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
await user.click(screen.getByText("Settings"));
await waitFor(() => {
expect(screen.getByTestId("skill-multiselect")).toBeInTheDocument();
});
// Initially no changes
expect(screen.getByText("Save Settings")).toBeDisabled();
// Add a skill
await user.click(screen.getByTestId("add-skill-test"));
// Save button should now be enabled
await waitFor(() => {
expect(screen.getByText("Save Settings")).not.toBeDisabled();
});
});
});
});

View File

@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
startAgentRun: vi.fn(),
fetchOrgTree: vi.fn(),
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
}));
vi.mock("../AgentDetailView", () => ({

View File

@@ -9,6 +9,19 @@ vi.mock("../../api", () => ({
createAgent: vi.fn(),
fetchModels: vi.fn(),
updateGlobalSettings: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
}));
// Mock SkillMultiselect
vi.mock("../SkillMultiselect", () => ({
SkillMultiselect: ({ value, onChange, id }: { value: string[]; onChange: (v: string[]) => void; id?: string }) => (
<div data-testid="skill-multiselect">
<span data-testid="skill-multiselect-value">{JSON.stringify(value)}</span>
<button data-testid="add-skill-1" onClick={() => onChange([...value, "skill-1"])}>Add Skill 1</button>
<button data-testid="add-skill-2" onClick={() => onChange([...value, "skill-2"])}>Add Skill 2</button>
<button data-testid="remove-skill-1" onClick={() => onChange(value.filter(s => s !== "skill-1"))}>Remove Skill 1</button>
</div>
),
}));
// Mock CustomModelDropdown to simplify interaction testing
@@ -93,6 +106,7 @@ vi.mock("../AgentGenerationModal", () => ({
const mockCreateAgent = vi.mocked(apiModule.createAgent);
const mockFetchModels = vi.mocked(apiModule.fetchModels);
const mockUpdateGlobalSettings = vi.mocked(apiModule.updateGlobalSettings);
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const MOCK_MODELS_RESPONSE = {
models: [
@@ -103,6 +117,11 @@ const MOCK_MODELS_RESPONSE = {
favoriteModels: ["anthropic/claude-sonnet-4-5"],
};
const MOCK_SKILLS_RESPONSE = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
{ id: "skill-2", name: "Skill Two", path: "/path/skill-2", relativePath: "skills/skill-2", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
];
describe("NewAgentDialog", () => {
const mockOnClose = vi.fn();
const mockOnCreated = vi.fn();
@@ -112,6 +131,7 @@ describe("NewAgentDialog", () => {
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
mockCreateAgent.mockResolvedValue({} as any);
mockUpdateGlobalSettings.mockResolvedValue({});
mockFetchDiscoveredSkills.mockResolvedValue(MOCK_SKILLS_RESPONSE);
});
describe("modal visibility", () => {
@@ -1134,4 +1154,98 @@ describe("NewAgentDialog", () => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
});
describe("skill selection", () => {
it("renders SkillMultiselect in Step 1", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Navigate to step 1
const nameInput = screen.getByLabelText(/Name/);
await user.type(nameInput, "Test Agent");
await user.click(screen.getByText("Next"));
// SkillMultiselect should be visible in step 1
expect(screen.getByTestId("skill-multiselect")).toBeTruthy();
});
it("shows selected skills in summary on step 2", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Navigate to step 1 and add a skill
const nameInput = screen.getByLabelText(/Name/);
await user.type(nameInput, "Test Agent");
await user.click(screen.getByText("Next"));
// Add a skill using the mocked button
await user.click(screen.getByTestId("add-skill-1"));
// Navigate to step 2
await user.click(screen.getByText("Next"));
// Summary should show skill count
expect(screen.getByText(/1 skill/)).toBeTruthy();
});
it("includes metadata.skills in createAgent call when skills are selected", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Navigate to step 1
const nameInput = screen.getByLabelText(/Name/);
await user.type(nameInput, "Test Agent");
await user.click(screen.getByText("Next"));
// Add skills
await user.click(screen.getByTestId("add-skill-1"));
await user.click(screen.getByTestId("add-skill-2"));
// Navigate to step 2 and create
await user.click(screen.getByText("Next"));
await user.click(screen.getByText("Create"));
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledOnce();
});
const createCall = mockCreateAgent.mock.calls[0][0];
expect(createCall.metadata).toEqual({ skills: ["skill-1", "skill-2"] });
});
it("does not include metadata when no skills are selected", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Navigate through steps without adding skills
const nameInput = screen.getByLabelText(/Name/);
await user.type(nameInput, "Test Agent");
await user.click(screen.getByText("Next"));
await user.click(screen.getByText("Next"));
await user.click(screen.getByText("Create"));
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledOnce();
});
const createCall = mockCreateAgent.mock.calls[0][0];
expect(createCall.metadata).toBeUndefined();
});
});
});