feat(FN-865): add AI agent generation service with dashboard UI

- Add agent generation service with OpenAI/Anthropic support, streaming, and error handling
- Add POST /agent/generate API endpoint with SSE streaming and cost tracking
- Add API client functions for agent generation with AbortController support
- Create AgentGenerationModal component with live preview, streaming, and import flow
- Integrate AI Generate button into NewAgentDialog with configuration options
- Add comprehensive unit tests for service and component
- Add changeset for @gsxdsm/fusion minor bump
This commit is contained in:
gsxdsm
2026-04-04 22:53:10 -07:00
parent e5391a05a5
commit 602534a1b7
8 changed files with 1781 additions and 4 deletions

View File

@@ -0,0 +1,433 @@
import { useState, useCallback, useEffect, useRef } from "react";
import type { AgentGenerationSpec } from "../api";
import {
startAgentGeneration,
generateAgentSpec,
cancelAgentGeneration,
} from "../api";
interface AgentGenerationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerated: (spec: AgentGenerationSpec) => void;
projectId?: string;
}
type ViewState =
| { type: "input" }
| { type: "loading" }
| { type: "preview"; spec: AgentGenerationSpec; sessionId: string };
const MIN_ROLE_LENGTH = 3;
const MAX_ROLE_LENGTH = 1000;
/**
* Modal for AI-assisted agent creation.
*
* The user enters a role description and the system generates a complete
* agent specification including title, icon, system prompt, and suggested
* runtime configuration.
*
* Follows the same general modal pattern as PlanningModeModal but simplified
* (no multi-step Q&A — single input → single generation result).
*/
export function AgentGenerationModal({
isOpen,
onClose,
onGenerated,
projectId,
}: AgentGenerationModalProps) {
const [roleDescription, setRoleDescription] = useState("");
const [view, setView] = useState<ViewState>({ type: "input" });
const [error, setError] = useState<string | null>(null);
const [systemPromptExpanded, setSystemPromptExpanded] = useState(false);
const sessionIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Focus textarea on open
useEffect(() => {
if (isOpen && view.type === "input") {
textareaRef.current?.focus();
}
}, [isOpen, view.type]);
// Cleanup session on unmount or modal close
useEffect(() => {
if (!isOpen && sessionIdRef.current) {
const sid = sessionIdRef.current;
sessionIdRef.current = null;
cancelAgentGeneration(sid, projectId).catch(() => {
/* ignore cleanup errors */
});
}
}, [isOpen, projectId]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleCancel = useCallback(() => {
// Cleanup session server-side
if (sessionIdRef.current) {
const sid = sessionIdRef.current;
sessionIdRef.current = null;
cancelAgentGeneration(sid, projectId).catch(() => {
/* ignore cleanup errors */
});
}
setRoleDescription("");
setView({ type: "input" });
setError(null);
setSystemPromptExpanded(false);
onClose();
}, [onClose, projectId]);
const handleGenerate = useCallback(async () => {
if (!roleDescription.trim() || roleDescription.trim().length < MIN_ROLE_LENGTH) return;
setError(null);
setView({ type: "loading" });
try {
// Phase 1: Start session
const { sessionId } = await startAgentGeneration(roleDescription.trim(), projectId);
sessionIdRef.current = sessionId;
// Phase 2: Generate spec (single combined loading state)
const { spec } = await generateAgentSpec(sessionId, projectId);
setView({ type: "preview", spec, sessionId });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to generate agent specification";
// Handle rate limit errors with user-friendly message
if (message.includes("429") || message.toLowerCase().includes("rate limit")) {
setError("Too many requests. Please wait a moment and try again.");
} else {
setError(message);
}
setView({ type: "input" });
sessionIdRef.current = null;
}
}, [roleDescription, projectId]);
const handleRegenerate = useCallback(async () => {
// Cancel existing session and create a new one
if (sessionIdRef.current) {
const oldSid = sessionIdRef.current;
sessionIdRef.current = null;
try {
await cancelAgentGeneration(oldSid, projectId);
} catch {
/* ignore */
}
}
// Re-run generation with same role description
await handleGenerate();
}, [handleGenerate, projectId]);
const handleUseSpec = useCallback(() => {
if (view.type !== "preview") return;
// Clear session ref so we don't cancel on close (we're using the spec)
sessionIdRef.current = null;
onGenerated(view.spec);
// Reset and close
setRoleDescription("");
setView({ type: "input" });
setError(null);
setSystemPromptExpanded(false);
onClose();
}, [view, onGenerated, onClose]);
if (!isOpen) return null;
const canGenerate =
roleDescription.trim().length >= MIN_ROLE_LENGTH &&
roleDescription.trim().length <= MAX_ROLE_LENGTH;
return (
<div
className="agent-dialog-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className="agent-dialog"
role="dialog"
aria-modal="true"
aria-label="Generate agent with AI"
style={{ width: 520, maxWidth: "90vw" }}
>
{/* Header */}
<div className="agent-dialog-header">
<span style={{ fontWeight: 600, fontSize: 15 }}>
<span style={{ marginRight: 8 }}></span>
Generate Agent
</span>
<button
className="btn-icon"
onClick={handleCancel}
aria-label="Close"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
fontSize: 18,
lineHeight: 1,
}}
>
×
</button>
</div>
{/* Body */}
<div className="agent-dialog-body">
{error && (
<div
style={{
color: "var(--state-error-text, #f85149)",
fontSize: 13,
padding: "8px 12px",
background: "var(--state-error-bg, rgba(248,81,73,0.1))",
borderRadius: 6,
marginBottom: 12,
}}
>
{error}
</div>
)}
{view.type === "input" && (
<div>
<p
style={{
color: "var(--text-muted)",
fontSize: 13,
marginTop: 0,
marginBottom: 12,
}}
>
Describe your agent&apos;s role and the AI will generate a complete
specification including system prompt, suggested configuration, and
more.
</p>
<div className="agent-dialog-field">
<label htmlFor="agent-role-description">Role Description</label>
<textarea
ref={textareaRef}
id="agent-role-description"
className="input"
rows={4}
placeholder='e.g. "Senior frontend code reviewer who specializes in React accessibility"'
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && canGenerate) {
e.preventDefault();
handleGenerate();
}
}}
maxLength={MAX_ROLE_LENGTH}
style={{
width: "100%",
boxSizing: "border-box",
resize: "vertical",
}}
aria-describedby="role-description-hint"
/>
<div
id="role-description-hint"
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
display: "flex",
justifyContent: "space-between",
}}
>
<span>Describe what your agent should do</span>
<span>
{roleDescription.length}/{MAX_ROLE_LENGTH}
</span>
</div>
</div>
</div>
)}
{view.type === "loading" && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "32px 16px",
gap: 12,
}}
>
<div
className="spin"
style={{
width: 32,
height: 32,
border: "3px solid var(--border)",
borderTopColor: "var(--text-accent, #58a6ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<p style={{ color: "var(--text-muted)", fontSize: 13, margin: 0 }}>
Generating agent specification...
</p>
</div>
)}
{view.type === "preview" && (
<div>
<div className="agent-dialog-summary" style={{ marginBottom: 12 }}>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
Title
</span>
<span style={{ fontWeight: 600 }}>
{view.spec.icon} {view.spec.title}
</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
Role
</span>
<span>{view.spec.role}</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
Description
</span>
<span style={{ fontSize: 13 }}>{view.spec.description}</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
Thinking
</span>
<span style={{ textTransform: "capitalize" }}>
{view.spec.thinkingLevel}
</span>
</div>
<div className="agent-dialog-summary-row">
<span
style={{ color: "var(--text-muted)", fontSize: 13, width: 90 }}
>
Max Turns
</span>
<span>{view.spec.maxTurns}</span>
</div>
</div>
{/* System prompt preview */}
<div className="agent-dialog-field">
<label>
System Prompt
<button
type="button"
style={{
background: "none",
border: "none",
color: "var(--text-accent, #58a6ff)",
cursor: "pointer",
fontSize: 12,
marginLeft: 8,
padding: 0,
}}
onClick={() => setSystemPromptExpanded(!systemPromptExpanded)}
>
{systemPromptExpanded ? "Collapse" : "Expand"}
</button>
</label>
<div
style={{
background: "var(--bg-secondary, #161b22)",
border: "1px solid var(--border)",
borderRadius: 6,
padding: 12,
fontSize: 12,
fontFamily: "monospace",
maxHeight: systemPromptExpanded ? "none" : 150,
overflow: systemPromptExpanded ? "auto" : "hidden",
position: "relative",
lineHeight: 1.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{view.spec.systemPrompt}
{!systemPromptExpanded &&
view.spec.systemPrompt.length > 500 && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 40,
background:
"linear-gradient(transparent, var(--bg-secondary, #161b22))",
pointerEvents: "none",
}}
/>
)}
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="agent-dialog-footer">
<button className="btn" onClick={handleCancel}>
Cancel
</button>
{view.type === "input" && (
<button
className="btn btn--primary"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
>
Generate
</button>
)}
{view.type === "preview" && (
<>
<button
className="btn"
onClick={() => void handleRegenerate()}
>
Regenerate
</button>
<button className="btn btn--primary" onClick={handleUseSpec}>
Use This
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from "react";
import type { AgentCapability, ModelInfo } from "../api";
import type { AgentCapability, ModelInfo, AgentGenerationSpec } from "../api";
import { createAgent, fetchModels } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentGenerationModal } from "./AgentGenerationModal";
export interface NewAgentDialogProps {
isOpen: boolean;
@@ -23,6 +24,9 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
/** Set of valid AgentCapability values for mapping generated roles */
const VALID_CAPABILITIES = new Set<string>(["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"]);
interface RuntimeConfig {
model: string;
thinkingLevel: ThinkingLevel;
@@ -33,6 +37,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [icon, setIcon] = useState("");
const [role, setRole] = useState<AgentCapability>("custom");
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
model: "",
@@ -41,6 +46,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
// Model dropdown state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
@@ -68,6 +74,26 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
? runtimeConfig.model
: "";
const handleGenerated = useCallback((spec: AgentGenerationSpec) => {
// Map generated role to AgentCapability, default to "custom" if unrecognized
const mappedRole = VALID_CAPABILITIES.has(spec.role)
? (spec.role as AgentCapability)
: "custom";
setName(spec.title);
setTitle(spec.description);
setIcon(spec.icon);
setRole(mappedRole);
setRuntimeConfig(c => ({
...c,
thinkingLevel: spec.thinkingLevel,
maxTurns: spec.maxTurns,
}));
setIsGenerationModalOpen(false);
// Advance to Step 1 so user can review model selection
setStep(1);
}, []);
const handleModelChange = useCallback((value: string) => {
// value is "provider/modelId" or "" for default
setRuntimeConfig(c => ({ ...c, model: value }));
@@ -97,9 +123,11 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
setStep(0);
setName("");
setTitle("");
setIcon("");
setRole("custom");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
setError(null);
setIsGenerationModalOpen(false);
onClose();
};
@@ -116,6 +144,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
name: name.trim(),
role,
...(title.trim() ? { title: title.trim() } : {}),
...(icon.trim() ? { icon: icon.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
}, projectId);
handleClose();
@@ -130,7 +159,8 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
const selectedRole = AGENT_ROLES.find(r => r.value === role);
return (
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<>
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
{/* Header */}
<div className="agent-dialog-header">
@@ -201,6 +231,21 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
))}
</div>
</div>
{/* AI-assisted generation */}
<div style={{ marginTop: 8, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
<button
type="button"
className="btn"
onClick={() => setIsGenerationModalOpen(true)}
style={{ width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 6 }}
>
<span></span>
Generate with AI
</button>
<p style={{ color: "var(--text-muted)", fontSize: 11, textAlign: "center", margin: "6px 0 0" }}>
Describe your agent&apos;s role and let AI generate a specification
</p>
</div>
</div>
)}
@@ -265,7 +310,10 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
<div className="agent-dialog-summary">
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
<span style={{ fontWeight: 600 }}>{name}</span>
<span style={{ fontWeight: 600 }}>
{icon && <span style={{ marginRight: 6 }}>{icon}</span>}
{name}
</span>
</div>
{title && (
<div className="agent-dialog-summary-row">
@@ -342,6 +390,15 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
)}
</div>
</div>
</div>
</div>
{/* AI-assisted agent generation modal */}
<AgentGenerationModal
isOpen={isGenerationModalOpen}
onClose={() => setIsGenerationModalOpen(false)}
onGenerated={handleGenerated}
projectId={projectId}
/>
</>
);
}

View File

@@ -39,6 +39,56 @@ vi.mock("../ProviderIcon", () => ({
),
}));
// Mock AgentGenerationModal
vi.mock("../AgentGenerationModal", () => ({
AgentGenerationModal: ({ isOpen, onClose, onGenerated }: { isOpen: boolean; onClose: () => void; onGenerated: (spec: any) => void }) => {
if (!isOpen) return null;
return (
<div data-testid="agent-generation-modal">
<span data-testid="generation-modal-open">Modal Open</span>
<button
data-testid="generation-modal-close"
onClick={onClose}
>
Close Modal
</button>
<button
data-testid="generation-modal-apply"
onClick={() =>
onGenerated({
title: "Generated Agent",
icon: "🤖",
role: "reviewer",
description: "Generated description for testing",
systemPrompt: "# System prompt\nYou are a helpful agent.",
thinkingLevel: "medium",
maxTurns: 25,
})
}
>
Apply Generated Spec
</button>
<button
data-testid="generation-modal-apply-custom-role"
onClick={() =>
onGenerated({
title: "Custom Role Agent",
icon: "🔧",
role: "security-auditor",
description: "Custom role not in AgentCapability",
systemPrompt: "# Security auditor prompt",
thinkingLevel: "high",
maxTurns: 50,
})
}
>
Apply Custom Role Spec
</button>
</div>
);
},
}));
const mockCreateAgent = vi.mocked(apiModule.createAgent);
const mockFetchModels = vi.mocked(apiModule.fetchModels);
@@ -371,4 +421,169 @@ describe("NewAgentDialog", () => {
expect(newNameInput.value).toBe("");
});
});
describe("AI generation integration", () => {
it("shows Generate with AI button in step 0", () => {
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
expect(screen.getByText("Generate with AI")).toBeTruthy();
});
it("opens AgentGenerationModal when Generate with AI is clicked", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
// Generation modal should not be open initially
expect(screen.queryByTestId("agent-generation-modal")).toBeNull();
// Click the Generate with AI button
await user.click(screen.getByText("Generate with AI"));
// Generation modal should now be open
expect(screen.getByTestId("agent-generation-modal")).toBeTruthy();
});
it("populates form fields and advances to step 1 when spec is applied", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Open generation modal and apply spec
await user.click(screen.getByText("Generate with AI"));
await user.click(screen.getByTestId("generation-modal-apply"));
// Should advance to step 1 (model config)
expect(screen.getByTestId("custom-model-dropdown")).toBeTruthy();
// Navigate to step 2 to verify the summary
await user.click(screen.getByText("Next"));
// Verify name was populated from spec.title
const summaryText = screen.getByText("Generated Agent");
expect(summaryText).toBeTruthy();
// Verify icon is shown
expect(screen.getByText("🤖")).toBeTruthy();
});
it("maps known role to AgentCapability", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Open generation modal and apply spec with role "reviewer"
await user.click(screen.getByText("Generate with AI"));
await user.click(screen.getByTestId("generation-modal-apply"));
// After generation, we're on Step 1 — navigate to summary (step 2)
await user.click(screen.getByText("Next"));
// Role should be mapped correctly to "Reviewer"
const roleRow = screen.getByText("Role").closest(".agent-dialog-summary-row");
expect(roleRow?.textContent).toContain("Reviewer");
});
it("maps unknown role to custom", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Open generation modal and apply spec with unknown role "security-auditor"
await user.click(screen.getByText("Generate with AI"));
await user.click(screen.getByTestId("generation-modal-apply-custom-role"));
// After generation, we're on Step 1 — navigate to summary (step 2)
await user.click(screen.getByText("Next"));
// Role should default to "Custom"
const roleRow = screen.getByText("Role").closest(".agent-dialog-summary-row");
expect(roleRow?.textContent).toContain("Custom");
});
it("applies runtime config from generated spec", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Open generation modal and apply spec
await user.click(screen.getByText("Generate with AI"));
await user.click(screen.getByTestId("generation-modal-apply"));
// Step 1: verify thinking level and max turns were applied
const thinkingSelect = screen.getByLabelText(/Thinking Level/) as HTMLSelectElement;
expect(thinkingSelect.value).toBe("medium");
const maxTurnsInput = screen.getByLabelText(/Max Turns/) as HTMLInputElement;
expect(maxTurnsInput.value).toBe("25");
});
it("closes generation modal without affecting form on cancel", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
// Fill in a name first
const nameInput = screen.getByLabelText(/Name/);
await user.type(nameInput, "Manual Name");
// Open generation modal
await user.click(screen.getByText("Generate with AI"));
expect(screen.getByTestId("agent-generation-modal")).toBeTruthy();
// Close the generation modal without applying
await user.click(screen.getByTestId("generation-modal-close"));
// Should still be on step 0 with original name
const nameAfter = screen.getByLabelText(/Name/) as HTMLInputElement;
expect(nameAfter.value).toBe("Manual Name");
expect(screen.queryByTestId("agent-generation-modal")).toBeNull();
});
it("creates agent with icon from generated spec", async () => {
const user = userEvent.setup();
render(
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} />,
);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledOnce());
// Open generation modal and apply spec
await user.click(screen.getByText("Generate with AI"));
await user.click(screen.getByTestId("generation-modal-apply"));
// After generation, we're on Step 1 — navigate to summary (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.name).toBe("Generated Agent");
expect(createCall.icon).toBe("🤖");
expect(createCall.title).toBe("Generated description for testing");
expect(createCall.role).toBe("reviewer");
expect(createCall.runtimeConfig).toEqual({
thinkingLevel: "medium",
maxTurns: 25,
});
});
});
});