feat(FN-3554): expand AI agent interview onboarding flow
Reuses the experimental onboarding modal for both create and edit, makes onboarding mode-aware, and plumbs draft handling through the agent detail view, new agent dialog, and import/export/generation routes. Fusion-Task-Id: FN-3554
This commit is contained in:
@@ -2683,6 +2683,29 @@ export interface AgentOnboardingSummary {
|
||||
templateId?: string;
|
||||
patternAgentId?: string;
|
||||
rationale?: string;
|
||||
model?: string;
|
||||
runtimeHint?: string;
|
||||
}
|
||||
|
||||
export type OnboardingMode = "create" | "edit";
|
||||
|
||||
export interface ExistingAgentOnboardingConfig {
|
||||
name?: string;
|
||||
role?: AgentCapability | "custom";
|
||||
title?: string;
|
||||
instructionsText?: string;
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
reportsTo?: string;
|
||||
skills?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
maxTurns?: number;
|
||||
runtimeHint?: string;
|
||||
heartbeatIntervalMs?: number;
|
||||
heartbeatTimeoutMs?: number;
|
||||
maxConcurrentRuns?: number;
|
||||
messageResponseMode?: "immediate" | "on-heartbeat";
|
||||
}
|
||||
|
||||
export type AgentOnboardingStreamEvent =
|
||||
@@ -2815,6 +2838,8 @@ export function startAgentOnboardingStreaming(
|
||||
context: {
|
||||
existingAgents: Array<{ id: string; name: string; role: string }>;
|
||||
templates: Array<{ id: string; label: string; description?: string }>;
|
||||
mode?: OnboardingMode;
|
||||
existingAgentConfig?: ExistingAgentOnboardingConfig;
|
||||
},
|
||||
projectId?: string,
|
||||
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
|
||||
@@ -2824,6 +2849,8 @@ export function startAgentOnboardingStreaming(
|
||||
body: JSON.stringify({
|
||||
intent,
|
||||
context,
|
||||
mode: context.mode,
|
||||
existingAgentConfig: context.existingAgentConfig,
|
||||
planningModelProvider: modelOverride?.planningModelProvider,
|
||||
planningModelId: modelOverride?.planningModelId,
|
||||
}),
|
||||
|
||||
@@ -922,6 +922,13 @@
|
||||
margin: 0 0 calc(var(--space-md) + var(--space-sm)) 0;
|
||||
}
|
||||
|
||||
.config-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.config-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent } from "../api";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
@@ -27,6 +27,7 @@ import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
|
||||
/**
|
||||
* Simple className utility - joins class names conditionally
|
||||
@@ -726,6 +727,9 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
onSaved={handleSavedMutation}
|
||||
onHasChangesChange={handleConfigChangesState}
|
||||
onDelete={handleDelete}
|
||||
onAgentDraftApplied={(updates) => {
|
||||
setAgent((current) => (current ? { ...current, ...updates } : current));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -3007,6 +3011,7 @@ function ConfigTab({
|
||||
onSaved,
|
||||
onHasChangesChange,
|
||||
onDelete,
|
||||
onAgentDraftApplied,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
@@ -3014,6 +3019,7 @@ function ConfigTab({
|
||||
onSaved: () => Promise<void>;
|
||||
onHasChangesChange?: (hasChanges: boolean) => void;
|
||||
onDelete?: () => Promise<void> | void;
|
||||
onAgentDraftApplied?: (updates: Partial<AgentDetail>) => void;
|
||||
}) {
|
||||
// Identity field state
|
||||
const [nameValue, setNameValue] = useState(agent.name);
|
||||
@@ -3024,6 +3030,7 @@ function ConfigTab({
|
||||
const [managerOptions, setManagerOptions] = useState<Agent[]>([]);
|
||||
const [isLoadingManagers, setIsLoadingManagers] = useState(false);
|
||||
const [isAvatarPending, setIsAvatarPending] = useState(false);
|
||||
const [isAiInterviewOpen, setIsAiInterviewOpen] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Local form state initialised from agent.metadata
|
||||
@@ -3104,6 +3111,89 @@ function ConfigTab({
|
||||
const hasMissingManagerSelection = !!managerSelection
|
||||
&& !availableManagers.some((candidate) => candidate.id === managerSelection);
|
||||
|
||||
const existingAgentConfig = useMemo(() => ({
|
||||
name: nameValue,
|
||||
role: roleValue,
|
||||
title: titleValue || undefined,
|
||||
instructionsText: agent.instructionsText,
|
||||
soul: agent.soul,
|
||||
memory: agent.memory,
|
||||
reportsTo: reportsToValue || undefined,
|
||||
skills: selectedSkills,
|
||||
model: modelValue || undefined,
|
||||
runtimeHint: runtimeMode === "runtime" ? selectedRuntimeId || undefined : undefined,
|
||||
thinkingLevel: (formValues.thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | undefined) ?? undefined,
|
||||
maxTurns: formValues.maxTurns ? Number(formValues.maxTurns) : undefined,
|
||||
heartbeatIntervalMs: heartbeatValues.heartbeatIntervalMs ? Number(heartbeatValues.heartbeatIntervalMs) * 1000 : undefined,
|
||||
heartbeatTimeoutMs: heartbeatValues.heartbeatTimeoutMs ? Number(heartbeatValues.heartbeatTimeoutMs) * 1000 : undefined,
|
||||
maxConcurrentRuns: heartbeatValues.maxConcurrentRuns ? Number(heartbeatValues.maxConcurrentRuns) : undefined,
|
||||
messageResponseMode: heartbeatValues.messageResponseMode as "immediate" | "on-heartbeat" | undefined,
|
||||
}), [
|
||||
agent.instructionsText,
|
||||
agent.memory,
|
||||
agent.soul,
|
||||
formValues.maxTurns,
|
||||
formValues.thinkingLevel,
|
||||
heartbeatValues.heartbeatIntervalMs,
|
||||
heartbeatValues.heartbeatTimeoutMs,
|
||||
heartbeatValues.maxConcurrentRuns,
|
||||
heartbeatValues.messageResponseMode,
|
||||
modelValue,
|
||||
nameValue,
|
||||
reportsToValue,
|
||||
roleValue,
|
||||
runtimeMode,
|
||||
selectedRuntimeId,
|
||||
selectedSkills,
|
||||
titleValue,
|
||||
]);
|
||||
|
||||
const applyInterviewDraft = useCallback((summary: AgentOnboardingSummary) => {
|
||||
setNameValue(summary.name);
|
||||
setRoleValue(summary.role);
|
||||
setTitleValue(summary.title ?? "");
|
||||
setIconValue(summary.icon ?? "");
|
||||
setReportsToValue(summary.reportsTo ?? "");
|
||||
|
||||
if (summary.skills) {
|
||||
setSelectedSkills(summary.skills);
|
||||
}
|
||||
if (summary.thinkingLevel) {
|
||||
setFormValues((prev) => ({ ...prev, thinkingLevel: summary.thinkingLevel }));
|
||||
}
|
||||
if (summary.maxTurns !== undefined) {
|
||||
setFormValues((prev) => ({ ...prev, maxTurns: String(summary.maxTurns) }));
|
||||
}
|
||||
if (summary.runtimeHint !== undefined) {
|
||||
setRuntimeMode("runtime");
|
||||
setSelectedRuntimeId(summary.runtimeHint ?? "");
|
||||
if (summary.runtimeHint) {
|
||||
setModelValue("");
|
||||
}
|
||||
} else if (summary.model !== undefined) {
|
||||
setRuntimeMode("model");
|
||||
setModelValue(summary.model ?? "");
|
||||
setSelectedRuntimeId("");
|
||||
}
|
||||
|
||||
const draftUpdates = Object.fromEntries(
|
||||
Object.entries({
|
||||
name: summary.name,
|
||||
role: summary.role,
|
||||
title: summary.title,
|
||||
icon: summary.icon,
|
||||
reportsTo: summary.reportsTo,
|
||||
instructionsText: summary.instructionsText,
|
||||
soul: summary.soul,
|
||||
memory: summary.memory,
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
) as Partial<AgentDetail>;
|
||||
onAgentDraftApplied?.(draftUpdates);
|
||||
|
||||
setIsAiInterviewOpen(false);
|
||||
addToast("Interview draft applied. Review and save when ready.", "success");
|
||||
}, [addToast, onAgentDraftApplied]);
|
||||
|
||||
// Load candidate managers for reports-to dropdown
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -3709,7 +3799,12 @@ function ConfigTab({
|
||||
<p className="config-description">
|
||||
Configure agent settings and behavior.
|
||||
</p>
|
||||
|
||||
<div className="config-actions-row">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setIsAiInterviewOpen(true)}>
|
||||
AI Interview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<label htmlFor="agent-name">Name</label>
|
||||
@@ -4406,6 +4501,16 @@ function ConfigTab({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExperimentalAgentOnboardingModal
|
||||
isOpen={isAiInterviewOpen}
|
||||
onClose={() => setIsAiInterviewOpen(false)}
|
||||
onUseDraft={applyInterviewDraft}
|
||||
projectId={projectId}
|
||||
existingAgents={managerOptions}
|
||||
mode="edit"
|
||||
existingAgentConfig={existingAgentConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Agent, AgentOnboardingSummary, ConversationHistoryEntry } from "../api";
|
||||
import type { Agent, AgentOnboardingSummary, ConversationHistoryEntry, ExistingAgentOnboardingConfig, OnboardingMode } from "../api";
|
||||
import {
|
||||
cancelAgentOnboarding,
|
||||
connectAgentOnboardingStream,
|
||||
@@ -18,6 +18,8 @@ interface ExperimentalAgentOnboardingModalProps {
|
||||
onUseDraft: (summary: AgentOnboardingSummary) => void;
|
||||
projectId?: string;
|
||||
existingAgents: Agent[];
|
||||
mode?: OnboardingMode;
|
||||
existingAgentConfig?: ExistingAgentOnboardingConfig;
|
||||
}
|
||||
|
||||
export function ExperimentalAgentOnboardingModal({
|
||||
@@ -26,6 +28,8 @@ export function ExperimentalAgentOnboardingModal({
|
||||
onUseDraft,
|
||||
projectId,
|
||||
existingAgents,
|
||||
mode = "create",
|
||||
existingAgentConfig,
|
||||
}: ExperimentalAgentOnboardingModalProps) {
|
||||
const [viewState, setViewState] = useState<ViewState>("initial");
|
||||
const [intent, setIntent] = useState("");
|
||||
@@ -36,6 +40,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
const [summary, setSummary] = useState<AgentOnboardingSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<ConversationHistoryEntry[]>([]);
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setViewState("initial");
|
||||
@@ -113,6 +118,8 @@ export function ExperimentalAgentOnboardingModal({
|
||||
const result = await startAgentOnboardingStreaming(
|
||||
intent,
|
||||
{
|
||||
mode,
|
||||
existingAgentConfig,
|
||||
existingAgents: existingAgents.map((agent) => ({ id: agent.id, name: agent.name, role: agent.role })),
|
||||
templates: templateOptions,
|
||||
},
|
||||
@@ -158,11 +165,11 @@ export function ExperimentalAgentOnboardingModal({
|
||||
|
||||
{viewState === "initial" && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="agent-onboarding-intent">What should this new agent own?</label>
|
||||
<label htmlFor="agent-onboarding-intent">{isEditMode ? "What should this agent change or improve?" : "What should this new agent own?"}</label>
|
||||
<textarea id="agent-onboarding-intent" className="input experimental-agent-onboarding-modal__textarea" value={intent} onChange={(e) => setIntent(e.target.value)} />
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>Start onboarding</button>
|
||||
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>{isEditMode ? "Start interview" : "Start onboarding"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -180,7 +187,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
|
||||
{viewState === "summary" && summary && (
|
||||
<div className="form-group">
|
||||
<label>Draft ready for review</label>
|
||||
<label>{isEditMode ? "Updated draft ready for review" : "Draft ready for review"}</label>
|
||||
<div className="experimental-agent-onboarding-modal__summary card">
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Profile</h4>
|
||||
@@ -228,7 +235,7 @@ export function ExperimentalAgentOnboardingModal({
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => onUseDraft(summary)}>Continue to agent form</button>
|
||||
<button className="btn btn-primary" onClick={() => onUseDraft(summary)}>{isEditMode ? "Apply draft to settings" : "Continue to agent form"}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -896,6 +896,7 @@ export function NewAgentDialog({
|
||||
}}
|
||||
projectId={projectId}
|
||||
existingAgents={existingAgents}
|
||||
mode="create"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -112,6 +112,43 @@ vi.mock("../SkillMultiselect", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
|
||||
ExperimentalAgentOnboardingModal: ({ isOpen, mode, existingAgentConfig, onUseDraft, onClose }: {
|
||||
isOpen: boolean;
|
||||
mode?: "create" | "edit";
|
||||
existingAgentConfig?: Record<string, unknown>;
|
||||
onUseDraft: (summary: any) => void;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
isOpen ? (
|
||||
<div data-testid="mock-ai-interview-modal">
|
||||
<span data-testid="mock-ai-interview-mode">{mode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUseDraft({
|
||||
name: "Interviewed Agent",
|
||||
role: "reviewer",
|
||||
title: "Draft Title",
|
||||
icon: "🧠",
|
||||
reportsTo: "agent-002",
|
||||
instructionsText: "Updated instructions",
|
||||
soul: "Updated soul",
|
||||
memory: "Updated memory",
|
||||
skills: ["skill-1"],
|
||||
thinkingLevel: "high",
|
||||
maxTurns: 12,
|
||||
model: "openai/gpt-4o",
|
||||
})}
|
||||
>
|
||||
Apply Draft
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>Close Modal</button>
|
||||
<pre data-testid="mock-ai-existing-config">{JSON.stringify(existingAgentConfig ?? {})}</pre>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn(() => () => {}),
|
||||
}));
|
||||
@@ -1580,6 +1617,35 @@ describe("AgentDetailView", () => {
|
||||
await user.click(screen.getByText("Settings"));
|
||||
};
|
||||
|
||||
it("opens AI Interview in edit mode and applies draft values to local settings fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
await user.click(await screen.findByRole("button", { name: "AI Interview" }));
|
||||
|
||||
expect(await screen.findByTestId("mock-ai-interview-modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-ai-interview-mode")).toHaveTextContent("edit");
|
||||
expect(screen.getByTestId("mock-ai-existing-config").textContent).toContain("Test Agent");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Apply Draft" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Interviewed Agent");
|
||||
expect((screen.getByLabelText("Title") as HTMLInputElement).value).toBe("Draft Title");
|
||||
expect((screen.getByLabelText("Icon") as HTMLInputElement).value).toBe("🧠");
|
||||
expect((screen.getByLabelText("Role") as HTMLSelectElement).value).toBe("reviewer");
|
||||
});
|
||||
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows settings delete control for idle and paused agents", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ExperimentalAgentOnboardingModal } from "../ExperimentalAgentOnboardingModal";
|
||||
import * as apiModule from "../../api";
|
||||
|
||||
let streamHandlers: any;
|
||||
|
||||
@@ -37,6 +38,8 @@ vi.mock("../../api", () => ({
|
||||
cancelAgentOnboarding: mockCancel,
|
||||
}));
|
||||
|
||||
const mockStartAgentOnboardingStreaming = vi.mocked(apiModule.startAgentOnboardingStreaming);
|
||||
|
||||
describe("ExperimentalAgentOnboardingModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -56,6 +59,14 @@ describe("ExperimentalAgentOnboardingModal", () => {
|
||||
fireEvent.change(screen.getByLabelText("What should this new agent own?"), { target: { value: "Review docs" } });
|
||||
fireEvent.click(screen.getByText("Start onboarding"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartAgentOnboardingStreaming).toHaveBeenCalledWith(
|
||||
"Review docs",
|
||||
expect.objectContaining({ mode: "create" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await screen.findByText("What should this agent primarily help with?");
|
||||
fireEvent.change(screen.getByLabelText("What should this agent primarily help with?"), { target: { value: "Docs" } });
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
@@ -82,6 +93,44 @@ describe("ExperimentalAgentOnboardingModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses edit mode copy and sends edit context", async () => {
|
||||
render(
|
||||
<ExperimentalAgentOnboardingModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onUseDraft={vi.fn()}
|
||||
existingAgents={[]}
|
||||
mode="edit"
|
||||
existingAgentConfig={{
|
||||
name: "Editor",
|
||||
instructionsText: "Current instructions",
|
||||
messageResponseMode: "on-heartbeat",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What should this agent change or improve?"), { target: { value: "Make it clearer" } });
|
||||
fireEvent.click(screen.getByText("Start interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartAgentOnboardingStreaming).toHaveBeenCalledWith(
|
||||
"Make it clearer",
|
||||
expect.objectContaining({
|
||||
mode: "edit",
|
||||
existingAgentConfig: expect.objectContaining({ name: "Editor" }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await screen.findByText("What should this agent primarily help with?");
|
||||
fireEvent.change(screen.getByLabelText("What should this agent primarily help with?"), { target: { value: "Docs" } });
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
|
||||
await screen.findByText("Updated draft ready for review");
|
||||
expect(screen.getByRole("button", { name: "Apply draft to settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders stream errors and still closes cleanly", async () => {
|
||||
render(
|
||||
<ExperimentalAgentOnboardingModal
|
||||
|
||||
@@ -28,10 +28,11 @@ vi.mock("../SkillMultiselect", () => ({
|
||||
|
||||
// Mock AgentGenerationModal
|
||||
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
|
||||
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => {
|
||||
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft, mode }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void; mode?: "create" | "edit" }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div role="dialog" aria-label="AI Interview">
|
||||
<div data-testid="interview-mode">{mode}</div>
|
||||
<button onClick={onClose}>Close Interview</button>
|
||||
<button
|
||||
onClick={() => onUseDraft({
|
||||
@@ -287,6 +288,7 @@ describe("NewAgentDialog", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "AI Interview" }));
|
||||
expect(screen.getByRole("dialog", { name: "AI Interview" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("interview-mode")).toHaveTextContent("create");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Apply Interview Draft" }));
|
||||
|
||||
|
||||
@@ -112,8 +112,9 @@ describe("agent-onboarding", () => {
|
||||
).toThrow(/Invalid summary/);
|
||||
});
|
||||
|
||||
it("builds compact onboarding context prompt", () => {
|
||||
it("builds compact onboarding context prompt for create mode", () => {
|
||||
const prompt = createAgentOnboardingSessionPrompt({
|
||||
mode: "create",
|
||||
intent: "Need a reviewer for docs",
|
||||
existingAgents: [{ id: "a1", name: "Alpha", role: "reviewer" }],
|
||||
templates: [{ id: "t1", label: "Reviewer Template", description: "General reviewer" }],
|
||||
@@ -122,6 +123,39 @@ describe("agent-onboarding", () => {
|
||||
expect(prompt).toContain("Need a reviewer for docs");
|
||||
expect(prompt).toContain("a1:Alpha(reviewer)");
|
||||
expect(prompt).toContain("t1:Reviewer Template");
|
||||
expect(prompt).not.toContain("Current agent configuration:");
|
||||
});
|
||||
|
||||
it("appends current agent configuration in edit mode prompt", () => {
|
||||
const prompt = createAgentOnboardingSessionPrompt({
|
||||
mode: "edit",
|
||||
intent: "Improve this agent",
|
||||
existingAgents: [{ id: "a1", name: "Alpha", role: "reviewer" }],
|
||||
templates: [{ id: "t1", label: "Reviewer Template", description: "General reviewer" }],
|
||||
existingAgentConfig: {
|
||||
name: "Alpha",
|
||||
role: "reviewer",
|
||||
title: "Senior Reviewer",
|
||||
instructionsText: "Current instructions",
|
||||
soul: "Calm",
|
||||
memory: "Team context",
|
||||
reportsTo: "mgr-1",
|
||||
skills: ["linting"],
|
||||
model: "openai/gpt-5-mini",
|
||||
thinkingLevel: "low",
|
||||
maxTurns: 40,
|
||||
runtimeHint: "gpu",
|
||||
heartbeatIntervalMs: 30000,
|
||||
heartbeatTimeoutMs: 120000,
|
||||
maxConcurrentRuns: 2,
|
||||
messageResponseMode: "immediate",
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Current agent configuration:");
|
||||
expect(prompt).toContain("name: Alpha");
|
||||
expect(prompt).toContain("instructionsText: Current instructions");
|
||||
expect(prompt).toContain("messageResponseMode: immediate");
|
||||
});
|
||||
|
||||
it("progresses through start -> question -> response -> final summary", async () => {
|
||||
@@ -168,6 +202,58 @@ describe("agent-onboarding", () => {
|
||||
expect(summary?.templateId).toBe("eng-template");
|
||||
});
|
||||
|
||||
it("defaults onboarding sessions to create mode", async () => {
|
||||
mockCreateFnAgent.mockResolvedValueOnce(
|
||||
createMockAgent([
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: { id: "q1", type: "text", question: "What should this agent do?" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const sessionId = await startAgentOnboardingSession(
|
||||
"127.0.0.1",
|
||||
{ intent: "create", existingAgents: [], templates: [] },
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)));
|
||||
expect(getAgentOnboardingSession(sessionId)?.mode).toBe("create");
|
||||
});
|
||||
|
||||
it("stores edit mode sessions and includes current config in agent prompt", async () => {
|
||||
mockCreateFnAgent.mockResolvedValueOnce(
|
||||
createMockAgent([
|
||||
JSON.stringify({
|
||||
type: "question",
|
||||
data: { id: "q1", type: "text", question: "What should change?" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const sessionId = await startAgentOnboardingSession(
|
||||
"127.0.0.1",
|
||||
{
|
||||
mode: "edit",
|
||||
intent: "Improve this agent",
|
||||
existingAgents: [],
|
||||
templates: [],
|
||||
existingAgentConfig: {
|
||||
name: "Editor",
|
||||
instructionsText: "Current instructions",
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
},
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion));
|
||||
expect(getAgentOnboardingSession(sessionId)?.mode).toBe("edit");
|
||||
expect(getAgentOnboardingSession(sessionId)?.contextPrompt).toContain("Current agent configuration:");
|
||||
expect(getAgentOnboardingSession(sessionId)?.contextPrompt).toContain("name: Editor");
|
||||
});
|
||||
|
||||
it("throws InvalidSessionStateError when responding without an active question", async () => {
|
||||
mockCreateFnAgent.mockResolvedValueOnce(
|
||||
createMockAgent([
|
||||
|
||||
@@ -20,6 +20,29 @@ export interface AgentOnboardingSummary {
|
||||
templateId?: string;
|
||||
patternAgentId?: string;
|
||||
rationale?: string;
|
||||
model?: string;
|
||||
runtimeHint?: string;
|
||||
}
|
||||
|
||||
export type OnboardingMode = "create" | "edit";
|
||||
|
||||
export interface ExistingAgentOnboardingConfig {
|
||||
name?: string;
|
||||
role?: AgentCapability | "custom";
|
||||
title?: string;
|
||||
instructionsText?: string;
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
reportsTo?: string;
|
||||
skills?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
||||
maxTurns?: number;
|
||||
runtimeHint?: string;
|
||||
heartbeatIntervalMs?: number;
|
||||
heartbeatTimeoutMs?: number;
|
||||
maxConcurrentRuns?: number;
|
||||
messageResponseMode?: "immediate" | "on-heartbeat";
|
||||
}
|
||||
|
||||
export type AgentOnboardingStreamEvent =
|
||||
@@ -58,6 +81,7 @@ type OnboardingAgent = Awaited<ReturnType<typeof engineCreateFnAgent>>;
|
||||
interface Session {
|
||||
id: string;
|
||||
ip: string;
|
||||
mode: OnboardingMode;
|
||||
contextPrompt: string;
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: AgentOnboardingSummary;
|
||||
@@ -164,28 +188,70 @@ export function parseAgentOnboardingResponse(text: string): { type: "question";
|
||||
}
|
||||
|
||||
export function createAgentOnboardingSessionPrompt(input: {
|
||||
mode: OnboardingMode;
|
||||
intent: string;
|
||||
existingAgents: Array<{ id: string; name: string; role: string }>;
|
||||
templates: Array<{ id: string; label: string; description?: string }>;
|
||||
existingAgentConfig?: ExistingAgentOnboardingConfig;
|
||||
}): string {
|
||||
const compactAgents = input.existingAgents.slice(0, 25).map((a) => `${a.id}:${a.name}(${a.role})`).join("\n") || "none";
|
||||
const compactTemplates = input.templates.slice(0, 25).map((t) => `${t.id}:${t.label}${t.description ? ` - ${t.description}` : ""}`).join("\n") || "none";
|
||||
return `User intent:\n${input.intent}\n\nExisting agents:\n${compactAgents}\n\nTemplate/preset options:\n${compactTemplates}`;
|
||||
const createContext = `User intent:\n${input.intent}\n\nExisting agents:\n${compactAgents}\n\nTemplate/preset options:\n${compactTemplates}`;
|
||||
|
||||
if (input.mode === "create") {
|
||||
return createContext;
|
||||
}
|
||||
|
||||
const currentConfig = input.existingAgentConfig ?? {};
|
||||
const currentConfigLines = [
|
||||
`name: ${currentConfig.name ?? ""}`,
|
||||
`role: ${currentConfig.role ?? ""}`,
|
||||
`title: ${currentConfig.title ?? ""}`,
|
||||
`instructionsText: ${currentConfig.instructionsText ?? ""}`,
|
||||
`soul: ${currentConfig.soul ?? ""}`,
|
||||
`memory: ${currentConfig.memory ?? ""}`,
|
||||
`reportsTo: ${currentConfig.reportsTo ?? ""}`,
|
||||
`skills: ${(currentConfig.skills ?? []).join(", ")}`,
|
||||
`model: ${currentConfig.model ?? ""}`,
|
||||
`thinkingLevel: ${currentConfig.thinkingLevel ?? ""}`,
|
||||
`maxTurns: ${currentConfig.maxTurns ?? ""}`,
|
||||
`runtimeHint: ${currentConfig.runtimeHint ?? ""}`,
|
||||
`heartbeatIntervalMs: ${currentConfig.heartbeatIntervalMs ?? ""}`,
|
||||
`heartbeatTimeoutMs: ${currentConfig.heartbeatTimeoutMs ?? ""}`,
|
||||
`maxConcurrentRuns: ${currentConfig.maxConcurrentRuns ?? ""}`,
|
||||
`messageResponseMode: ${currentConfig.messageResponseMode ?? ""}`,
|
||||
].join("\n");
|
||||
|
||||
return `${createContext}\n\nCurrent agent configuration:\n${currentConfigLines}`;
|
||||
}
|
||||
|
||||
export async function startAgentOnboardingSession(
|
||||
ip: string,
|
||||
initialContext: { intent: string; existingAgents: Array<{ id: string; name: string; role: string }>; templates: Array<{ id: string; label: string; description?: string }> },
|
||||
initialContext: {
|
||||
mode?: OnboardingMode;
|
||||
intent: string;
|
||||
existingAgents: Array<{ id: string; name: string; role: string }>;
|
||||
templates: Array<{ id: string; label: string; description?: string }>;
|
||||
existingAgentConfig?: ExistingAgentOnboardingConfig;
|
||||
},
|
||||
rootDir: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<string> {
|
||||
const id = randomUUID();
|
||||
const mode: OnboardingMode = initialContext.mode ?? "create";
|
||||
const session: Session = {
|
||||
id,
|
||||
ip,
|
||||
contextPrompt: createAgentOnboardingSessionPrompt(initialContext),
|
||||
mode,
|
||||
contextPrompt: createAgentOnboardingSessionPrompt({
|
||||
mode,
|
||||
intent: initialContext.intent,
|
||||
existingAgents: initialContext.existingAgents,
|
||||
templates: initialContext.templates,
|
||||
existingAgentConfig: initialContext.existingAgentConfig,
|
||||
}),
|
||||
history: [],
|
||||
thinkingOutput: "",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request } from "../../test-request.js";
|
||||
|
||||
const mockStartAgentOnboardingSession = vi.fn();
|
||||
|
||||
vi.mock("../../agent-onboarding.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../agent-onboarding.js")>("../../agent-onboarding.js");
|
||||
return {
|
||||
...actual,
|
||||
startAgentOnboardingSession: mockStartAgentOnboardingSession,
|
||||
};
|
||||
});
|
||||
|
||||
function createMockStore(): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsFast: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getAgentLogCount: vi.fn().mockResolvedValue(0),
|
||||
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
getTaskDocuments: vi.fn().mockResolvedValue([]),
|
||||
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
|
||||
getAllDocuments: vi.fn().mockResolvedValue([]),
|
||||
upsertTaskDocument: vi.fn(),
|
||||
deleteTaskDocument: vi.fn(),
|
||||
updatePrInfo: vi.fn(),
|
||||
updateIssueInfo: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
getDatabase: vi.fn(),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function setupApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(createMockStore()));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("agent onboarding routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStartAgentOnboardingSession.mockResolvedValue("session-123");
|
||||
});
|
||||
|
||||
it("defaults mode to create when omitted", async () => {
|
||||
const app = setupApp();
|
||||
const res = await request(app, "POST", "/api/agents/onboarding/start-streaming", JSON.stringify({
|
||||
intent: "Create a reviewer",
|
||||
context: { existingAgents: [], templates: [] },
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockStartAgentOnboardingSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartAgentOnboardingSession.mock.calls[0]?.[1]).toMatchObject({ mode: "create" });
|
||||
});
|
||||
|
||||
it("accepts edit mode and forwards existingAgentConfig", async () => {
|
||||
const app = setupApp();
|
||||
const res = await request(app, "POST", "/api/agents/onboarding/start-streaming", JSON.stringify({
|
||||
intent: "Improve this agent",
|
||||
mode: "edit",
|
||||
existingAgentConfig: {
|
||||
name: "Editor",
|
||||
instructionsText: "Current instructions",
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
context: { existingAgents: [], templates: [] },
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockStartAgentOnboardingSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartAgentOnboardingSession.mock.calls[0]?.[1]).toMatchObject({
|
||||
mode: "edit",
|
||||
existingAgentConfig: {
|
||||
name: "Editor",
|
||||
instructionsText: "Current instructions",
|
||||
messageResponseMode: "on-heartbeat",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid mode", async () => {
|
||||
const app = setupApp();
|
||||
const res = await request(app, "POST", "/api/agents/onboarding/start-streaming", JSON.stringify({
|
||||
intent: "x",
|
||||
mode: "bad",
|
||||
context: { existingAgents: [], templates: [] },
|
||||
}), { "Content-Type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body?.error).toContain("mode must be 'create' or 'edit'");
|
||||
expect(mockStartAgentOnboardingSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -807,12 +807,16 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
||||
|
||||
router.post("/agents/onboarding/start-streaming", async (req, res) => {
|
||||
try {
|
||||
const { intent, context, planningModelProvider, planningModelId } = req.body as {
|
||||
const { intent, context, mode, existingAgentConfig, planningModelProvider, planningModelId } = req.body as {
|
||||
intent?: string;
|
||||
context?: {
|
||||
existingAgents?: Array<{ id: string; name: string; role: string }>;
|
||||
templates?: Array<{ id: string; label: string; description?: string }>;
|
||||
mode?: "create" | "edit";
|
||||
existingAgentConfig?: Record<string, unknown>;
|
||||
};
|
||||
mode?: "create" | "edit";
|
||||
existingAgentConfig?: Record<string, unknown>;
|
||||
planningModelProvider?: string;
|
||||
planningModelId?: string;
|
||||
};
|
||||
@@ -821,6 +825,13 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
||||
throw badRequest("intent is required and must be a string");
|
||||
}
|
||||
|
||||
const resolvedMode = mode ?? context?.mode ?? "create";
|
||||
if (resolvedMode !== "create" && resolvedMode !== "edit") {
|
||||
throw badRequest("mode must be 'create' or 'edit'");
|
||||
}
|
||||
|
||||
const resolvedExistingAgentConfig = existingAgentConfig ?? context?.existingAgentConfig;
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
@@ -828,9 +839,11 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
||||
const sessionId = await startAgentOnboardingSession(
|
||||
ip,
|
||||
{
|
||||
mode: resolvedMode,
|
||||
intent,
|
||||
existingAgents: Array.isArray(context?.existingAgents) ? context.existingAgents : [],
|
||||
templates: Array.isArray(context?.templates) ? context.templates : [],
|
||||
existingAgentConfig: resolvedExistingAgentConfig,
|
||||
},
|
||||
scopedStore.getRootDir(),
|
||||
planningModelProvider,
|
||||
|
||||
Reference in New Issue
Block a user