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:
433
packages/dashboard/app/components/AgentGenerationModal.tsx
Normal file
433
packages/dashboard/app/components/AgentGenerationModal.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user