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:
Fusion
2026-05-07 07:47:33 -07:00
committed by gsxdsm
parent 760621cc8c
commit 1f5e102312
14 changed files with 578 additions and 19 deletions

View File

@@ -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,
}),

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
)}

View File

@@ -896,6 +896,7 @@ export function NewAgentDialog({
}}
projectId={projectId}
existingAgents={existingAgents}
mode="create"
/>
</div>,
document.body,

View File

@@ -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();

View File

@@ -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

View File

@@ -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" }));

View File

@@ -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([

View File

@@ -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(),

View File

@@ -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();
});
});

View File

@@ -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,