feat(FN-3025): add agent onboarding flow with launch gating

- Add backend onboarding session service and legacy API routes for start, state, and import/export generation
- Introduce experimental AgentOnboardingModal UI with stream-state handling, model selector wiring, and onboarding launch flow in AgentsView
- Add onboarding gating via prompt overrides/store updates and include search behavior fix for partial-match filtering
- Expand dashboard/core tests for onboarding API, modal behavior, agents/settings integration, and prompt override handling

Fusion-Task-Id: FN-3025
This commit is contained in:
Fusion
2026-04-30 21:20:31 -07:00
committed by gsxdsm
parent 0f4031824e
commit 00de40da91
15 changed files with 1218 additions and 8 deletions

View File

@@ -373,6 +373,7 @@ function AppInner() {
const skillsEnabled = experimentalFeatures.skillsView === true;
const nodesEnabled = experimentalFeatures.nodesView === true;
const researchEnabled = experimentalFeatures.researchView === true;
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
const agentsEnabled = true;
// Redirect to board if feature-gated views are disabled.
@@ -688,7 +689,12 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<AgentsView addToast={addToast} projectId={currentProject?.id} onOpenTaskLogs={handleOpenTaskLogs} />
<AgentsView
addToast={addToast}
projectId={currentProject?.id}
onOpenTaskLogs={handleOpenTaskLogs}
agentOnboardingEnabled={agentOnboardingEnabled}
/>
</Suspense>
</PageErrorBoundary>
);

View File

@@ -41,6 +41,11 @@ import {
saveWorkspaceFileContent,
deleteFile,
startPlanningStreaming,
startAgentOnboardingStreaming,
respondToAgentOnboarding,
retryAgentOnboardingSession,
stopAgentOnboardingGeneration,
cancelAgentOnboarding,
fetchTasks,
summarizeTitle,
fetchProjects,
@@ -6276,3 +6281,58 @@ describe("updatePiExtensions", () => {
await expect(updatePiExtensions(["ext-1"])).rejects.toThrow();
});
});
describe("agent onboarding API wrappers", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("starts onboarding streaming session with context payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { sessionId: "onb-1" }, 201));
const result = await startAgentOnboardingStreaming(
"Need a docs reviewer",
{
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
},
"proj-123",
{ planningModelProvider: "openai", planningModelId: "gpt-4o" },
);
expect(result.sessionId).toBe("onb-1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/onboarding/start-streaming?projectId=proj-123", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({
intent: "Need a docs reviewer",
context: {
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
},
planningModelProvider: "openai",
planningModelId: "gpt-4o",
}),
});
});
it("posts onboarding response/retry/stop/cancel endpoints", async () => {
globalThis.fetch = vi.fn()
.mockReturnValueOnce(mockFetchResponse(true, { type: "question", data: { id: "q1", type: "text", question: "?" } }))
.mockReturnValueOnce(mockFetchResponse(true, { success: true, sessionId: "onb-1" }))
.mockReturnValueOnce(mockFetchResponse(true, { success: true }))
.mockReturnValueOnce(mockFetchResponse(true, {}));
await respondToAgentOnboarding("onb-1", { q1: "answer" }, "proj-123");
await retryAgentOnboardingSession("onb-1", "proj-123");
await stopAgentOnboardingGeneration("onb-1", "proj-123");
await cancelAgentOnboarding("onb-1", "proj-123");
expect(globalThis.fetch).toHaveBeenNthCalledWith(1, "/api/agents/onboarding/respond?projectId=proj-123", expect.objectContaining({ method: "POST" }));
expect(globalThis.fetch).toHaveBeenNthCalledWith(2, "/api/agents/onboarding/onb-1/retry?projectId=proj-123", expect.objectContaining({ method: "POST" }));
expect(globalThis.fetch).toHaveBeenNthCalledWith(3, "/api/agents/onboarding/onb-1/stop?projectId=proj-123", expect.objectContaining({ method: "POST" }));
expect(globalThis.fetch).toHaveBeenNthCalledWith(4, "/api/agents/onboarding/cancel?projectId=proj-123", expect.objectContaining({ method: "POST" }));
});
});

View File

@@ -2456,6 +2456,30 @@ export type PlanningStreamEvent =
| { type: "error"; data: string }
| { type: "complete"; data: Record<string, never> };
export interface AgentOnboardingSummary {
name: string;
role: AgentCapability | "custom";
instructionsText: string;
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
maxTurns: number;
title?: string;
icon?: string;
reportsTo?: string;
soul?: string;
memory?: string;
skills?: string[];
templateId?: string;
patternAgentId?: string;
rationale?: string;
}
export type AgentOnboardingStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: AgentOnboardingSummary }
| { type: "error"; data: string }
| { type: "complete"; data: Record<string, never> };
/** Start a new planning session with an initial plan */
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
@@ -2531,6 +2555,56 @@ export function cancelPlanning(sessionId: string, projectId?: string, tabId?: st
});
}
export function startAgentOnboardingStreaming(
intent: string,
context: {
existingAgents: Array<{ id: string; name: string; role: string }>;
templates: Array<{ id: string; label: string; description?: string }>;
},
projectId?: string,
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/agents/onboarding/start-streaming", projectId), {
method: "POST",
body: JSON.stringify({
intent,
context,
planningModelProvider: modelOverride?.planningModelProvider,
planningModelId: modelOverride?.planningModelId,
}),
});
}
export function respondToAgentOnboarding(
sessionId: string,
responses: Record<string, unknown>,
projectId?: string,
): Promise<{ type: "question" | "complete"; data: PlanningQuestion | AgentOnboardingSummary }> {
return api(withProjectId("/agents/onboarding/respond", projectId), {
method: "POST",
body: JSON.stringify({ sessionId, responses }),
});
}
export function retryAgentOnboardingSession(sessionId: string, projectId?: string): Promise<{ success: boolean; sessionId: string }> {
return api(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/retry`, projectId), {
method: "POST",
});
}
export function stopAgentOnboardingGeneration(sessionId: string, projectId?: string): Promise<{ success: boolean }> {
return api(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/stop`, projectId), {
method: "POST",
});
}
export function cancelAgentOnboarding(sessionId: string, projectId?: string): Promise<void> {
return api(withProjectId("/agents/onboarding/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}
/** Create a task from a completed planning session */
export function createTaskFromPlanning(
sessionId: string,
@@ -3613,6 +3687,63 @@ export function getPlanningStreamUrl(sessionId: string, projectId?: string): str
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
}
export function getAgentOnboardingStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/stream`, projectId));
}
export function connectAgentOnboardingStream(
sessionId: string,
projectId: string | undefined,
handlers: {
onThinking?: (data: string) => void;
onQuestion?: (data: PlanningQuestion) => void;
onSummary?: (data: AgentOnboardingSummary) => void;
onError?: (data: string) => void;
onComplete?: () => void;
onConnectionStateChange?: (state: StreamConnectionState) => void;
},
options?: { maxReconnectAttempts?: number },
): { close: () => void; isConnected: () => boolean } {
const url = getAgentOnboardingStreamUrl(sessionId, projectId);
const resilient = createResilientEventSource(
url,
{
events: {
thinking: (event) => {
try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); }
},
question: (event) => {
try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch {}
},
summary: (event) => {
try { handlers.onSummary?.(JSON.parse(event.data) as AgentOnboardingSummary); } catch {}
},
error: (event) => {
try {
const parsed = JSON.parse(event.data);
handlers.onError?.(parsed.message || parsed);
} catch {
handlers.onError?.(event.data || "Stream error");
}
},
complete: () => {
handlers.onComplete?.();
},
},
},
{
maxReconnectAttempts: options?.maxReconnectAttempts,
onConnectionStateChange: handlers.onConnectionStateChange,
onFatalError: (message) => handlers.onError?.(message),
},
);
return {
close: resilient.close,
isConnected: resilient.isConnected,
};
}
/** Connect to planning session SSE stream and handle events
*
* Returns an object with:

View File

@@ -0,0 +1,22 @@
.agent-onboarding-modal {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.agent-onboarding-summary {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
@media (max-width: 768px) {
.agent-onboarding-modal {
width: 100%;
max-width: 100%;
}
}

View File

@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from "react";
import type { AgentCapability, ConversationHistoryEntry } from "../api";
import {
startAgentOnboardingStreaming,
respondToAgentOnboarding,
retryAgentOnboardingSession,
stopAgentOnboardingGeneration,
cancelAgentOnboarding,
createAgent,
connectAgentOnboardingStream,
fetchModels,
type Agent,
type AgentOnboardingSummary,
type ModelInfo,
} from "../api";
import { AGENT_PRESETS } from "./agent-presets";
import { ConversationHistory } from "./ConversationHistory";
import { CustomModelDropdown } from "./CustomModelDropdown";
import "./AgentOnboardingModal.css";
type ViewState = "initial" | "loading" | "question" | "summary" | "creating" | "error";
interface AgentOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
onCreated: () => void;
addToast: (message: string, type?: "success" | "error") => void;
projectId?: string;
existingAgents: Agent[];
}
export function AgentOnboardingModal({ isOpen, onClose, onCreated, addToast, projectId, existingAgents }: AgentOnboardingModalProps) {
const [viewState, setViewState] = useState<ViewState>("initial");
const [intent, setIntent] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<string>("");
const [currentQuestionId, setCurrentQuestionId] = useState<string>("answer");
const [answer, setAnswer] = useState("");
const [summary, setSummary] = useState<AgentOnboardingSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<ConversationHistoryEntry[]>([]);
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">("model");
const [model, setModel] = useState<string>("");
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const templateOptions = useMemo(
() => AGENT_PRESETS.map((preset) => ({ id: preset.id, label: preset.name, description: preset.description })),
[],
);
useEffect(() => {
void fetchModels().then((data) => setAvailableModels(data.models)).catch(() => setAvailableModels([]));
}, []);
useEffect(() => {
if (!sessionId) return;
const stream = connectAgentOnboardingStream(sessionId, projectId, {
onThinking: (data) => {
setHistory((current) => {
const next = [...current];
const last = next[next.length - 1];
if (last && !last.question) {
next[next.length - 1] = { ...last, thinkingOutput: `${last.thinkingOutput ?? ""}${data}` };
return next;
}
return [...next, { response: {}, thinkingOutput: data }];
});
},
onQuestion: (q) => {
setCurrentQuestion(q.question);
setCurrentQuestionId(q.id);
setViewState("question");
},
onSummary: (nextSummary) => {
setSummary(nextSummary);
setViewState("summary");
},
onError: (message) => {
setError(message);
setViewState("error");
},
onComplete: () => {
if (summary) {
setViewState("summary");
}
},
onConnectionStateChange: (state) => {
if (state === "reconnecting") {
setError("Connection lost. Retrying...");
}
},
});
return () => stream.close();
}, [sessionId, projectId]);
const handleClose = async () => {
if (sessionId) {
await cancelAgentOnboarding(sessionId, projectId);
}
onClose();
};
if (!isOpen) return null;
const start = async () => {
setViewState("loading");
setError(null);
try {
const result = await startAgentOnboardingStreaming(
intent,
{
existingAgents: existingAgents.map((agent) => ({ id: agent.id, name: agent.name, role: agent.role })),
templates: templateOptions,
},
projectId,
);
setSessionId(result.sessionId);
} catch (err) {
setError((err as Error).message);
setViewState("error");
}
};
const submitAnswer = async () => {
if (!sessionId) return;
setViewState("loading");
setError(null);
try {
const responsePayload = { [currentQuestionId]: answer };
setHistory((current) => [
...current,
{
question: { id: currentQuestionId, type: "text", question: currentQuestion },
response: responsePayload,
},
]);
await respondToAgentOnboarding(sessionId, responsePayload, projectId);
setAnswer("");
} catch (err) {
setError((err as Error).message);
setViewState("error");
}
};
const createFromSummary = async () => {
if (!summary) return;
setViewState("creating");
setError(null);
try {
await createAgent(
{
name: summary.name,
role: summary.role as AgentCapability,
title: summary.title,
icon: summary.icon,
reportsTo: summary.reportsTo,
instructionsText: summary.instructionsText,
soul: summary.soul,
memory: summary.memory,
runtimeConfig: {
thinkingLevel: summary.thinkingLevel,
maxTurns: summary.maxTurns,
...(runtimeMode === "model" && model ? { model } : {}),
...(runtimeMode === "runtime" ? { runtimeHint: "onboarding" } : {}),
},
metadata: summary.skills ? { skills: summary.skills } : undefined,
},
projectId,
);
addToast(`Agent \"${summary.name}\" created`, "success");
onCreated();
} catch (err) {
setError((err as Error).message);
setViewState("error");
}
};
return (
<div className="modal-overlay open" role="presentation">
<div className="modal modal-lg agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="Agent onboarding">
<div className="modal-header">
<h3>Agent Onboarding</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
</div>
{history.length > 0 && <ConversationHistory entries={history} />}
{viewState === "initial" && (
<div className="form-group">
<label htmlFor="agent-onboarding-intent">What do you want this agent to do?</label>
<textarea id="agent-onboarding-intent" className="input" 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>
</div>
</div>
)}
{(viewState === "loading" || viewState === "question") && (
<div className="form-group">
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Waiting for AI question..."}</label>
<textarea id="agent-onboarding-answer" className="input" value={answer} onChange={(e) => setAnswer(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => sessionId && void stopAgentOnboardingGeneration(sessionId, projectId)}>Stop</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
</div>
</div>
)}
{viewState === "summary" && summary && (
<div className="form-group">
<label>Review generated configuration</label>
<div className="agent-onboarding-summary">
<p><strong>Name:</strong> {summary.name}</p>
<p><strong>Role:</strong> {summary.role}</p>
<label htmlFor="thinking-level">Thinking level</label>
<input id="thinking-level" className="input" value={summary.thinkingLevel} onChange={() => {}} readOnly />
<label htmlFor="max-turns">Max turns</label>
<input id="max-turns" className="input" type="number" value={summary.maxTurns} onChange={() => {}} readOnly />
<label htmlFor="runtime-mode">Runtime mode</label>
<select id="runtime-mode" className="select" value={runtimeMode} onChange={(e) => setRuntimeMode(e.target.value as "model" | "runtime")}>
<option value="model">Model</option>
<option value="runtime">Runtime</option>
</select>
{runtimeMode === "model" && (
<>
<label>Model</label>
<CustomModelDropdown
id="agent-onboarding-model"
label="Model"
value={model}
onChange={setModel}
models={availableModels}
placeholder="Select a model…"
/>
</>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" onClick={() => void createFromSummary()}>Create agent</button>
</div>
</div>
)}
{viewState === "creating" && (
<div className="form-group agent-onboarding-creating">Creating agent...</div>
)}
{viewState === "error" && error && (
<div className="form-group">
<div className="form-error">{error}</div>
<div className="modal-actions">
<button className="btn" onClick={() => sessionId && void retryAgentOnboardingSession(sessionId, projectId)}>Retry</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
import type { AgentNode } from "../hooks/useAgentHierarchy";
import { NewAgentDialog } from "./NewAgentDialog";
import { AgentOnboardingModal } from "./AgentOnboardingModal";
import { AgentImportModal } from "./AgentImportModal";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getAgentHealthStatus } from "../utils/agentHealth";
@@ -31,6 +32,7 @@ export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void;
projectId?: string;
onOpenTaskLogs?: (taskId: string) => void;
agentOnboardingEnabled?: boolean;
}
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
@@ -258,7 +260,7 @@ function OrgChartNode({
);
}
export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewProps) {
export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardingEnabled = false }: AgentsViewProps) {
const [showSystemAgents, setShowSystemAgents] = useState(false);
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
@@ -266,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
showSystemAgents,
});
const [isCreating, setIsCreating] = useState(false);
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
@@ -715,6 +718,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
const showInitialAgentsLoading = isLoading && agents.length === 0;
const handleOpenNewAgent = useCallback(() => {
if (agentOnboardingEnabled) {
setIsOnboardingOpen(true);
return;
}
setIsCreating(true);
}, [agentOnboardingEnabled]);
return (
<div className="agents-view">
<div className="agents-view-header">
@@ -785,7 +796,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
<button
className="btn btn-task-create btn-sm"
onClick={() => {
setIsCreating(true);
handleOpenNewAgent();
setIsControlsPanelOpen(false);
}}
>
@@ -915,6 +926,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
projectId={projectId}
/>
<AgentOnboardingModal
isOpen={isOnboardingOpen}
onClose={() => setIsOnboardingOpen(false)}
onCreated={() => {
setIsOnboardingOpen(false);
void loadAgents();
}}
addToast={addToast}
projectId={projectId}
existingAgents={agents}
/>
<AgentImportModal
isOpen={isImporting}
onClose={() => setIsImporting(false)}
@@ -931,7 +954,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
) : agentView === "tree" ? (
<div className="agent-tree__view">
{displayAgents.length === 0 ? (
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
hierarchy.rootNodes.map((node) => (
<AgentTreeNode
@@ -956,7 +979,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
<span>Loading org chart...</span>
</div>
) : displayOrgTree.length === 0 ? (
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
displayOrgTree.map((node) => (
<OrgChartNode
@@ -973,7 +996,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
) : agentView === "board" ? (
<div className="agent-board">
{displayAgents.length === 0 ? (
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
displayAgents.map((agent) => {
const health = getHealthStatus(agent);
@@ -1007,7 +1030,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
) : (
<div className="agent-list">
{displayAgents.length === 0 ? (
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
) : (
// List view: detailed card layout
displayAgents.map(agent => {

View File

@@ -244,6 +244,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
devServerView: "Dev Server",
todoView: "Todo List",
researchView: "Research View",
agentOnboarding: "Planning-style Agent Onboarding",
};
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AgentOnboardingModal } from "../AgentOnboardingModal";
let streamHandlers: any;
let respondCount = 0;
vi.mock("../../api", () => ({
startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
connectAgentOnboardingStream: vi.fn().mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
setTimeout(() => handlers.onQuestion?.({ id: "q1", type: "text", question: "What should this agent primarily help with?" }), 0);
return { close: vi.fn(), isConnected: vi.fn(() => true) };
}),
respondToAgentOnboarding: vi.fn().mockImplementation(() => {
respondCount += 1;
if (respondCount === 1) {
setTimeout(() => streamHandlers?.onQuestion?.({ id: "q2", type: "text", question: "Second question" }), 0);
} else {
setTimeout(() => streamHandlers?.onSummary?.({
name: "Docs Reviewer",
role: "reviewer",
instructionsText: "Review docs",
thinkingLevel: "medium",
maxTurns: 20,
}), 0);
}
return Promise.resolve({ type: "question", data: {} });
}),
retryAgentOnboardingSession: vi.fn().mockResolvedValue({ success: true }),
stopAgentOnboardingGeneration: vi.fn().mockResolvedValue({ success: true }),
cancelAgentOnboarding: vi.fn().mockResolvedValue(undefined),
createAgent: vi.fn().mockResolvedValue({ id: "agent-1" }),
}));
describe("AgentOnboardingModal", () => {
it("walks onboarding flow through summary and create", async () => {
const onCreated = vi.fn();
render(
<AgentOnboardingModal
isOpen={true}
onClose={vi.fn()}
onCreated={onCreated}
addToast={vi.fn()}
existingAgents={[]}
/>,
);
fireEvent.change(screen.getByLabelText("What do you want this agent to do?"), { target: { value: "Review docs" } });
fireEvent.click(screen.getByText("Start onboarding"));
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("Second question");
fireEvent.change(screen.getByLabelText("Second question"), { target: { value: "More docs" } });
fireEvent.click(screen.getByText("Continue"));
await screen.findByText("Review generated configuration");
fireEvent.click(screen.getByText("Create agent"));
await waitFor(() => {
expect(onCreated).toHaveBeenCalled();
});
});
});

View File

@@ -20,6 +20,11 @@ vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
fetchPluginRuntimes: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }),
respondToAgentOnboarding: vi.fn().mockResolvedValue({ type: "question", data: { id: "q1", type: "text", question: "?" } }),
retryAgentOnboardingSession: vi.fn().mockResolvedValue({ success: true, sessionId: "onb-1" }),
stopAgentOnboardingGeneration: vi.fn().mockResolvedValue({ success: true }),
cancelAgentOnboarding: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../AgentDetailView", () => ({
@@ -1147,6 +1152,35 @@ describe("AgentsView", () => {
expect(screen.getByPlaceholderText("e.g. Frontend Reviewer")).toBeTruthy();
});
it("keeps legacy dialog launch when agent onboarding flag is disabled", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={false} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
});
});
it("opens onboarding modal and not legacy dialog when agent onboarding flag is enabled", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={true} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Agent onboarding" })).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "Create new agent" })).toBeNull();
});
});
it("does not allow proceeding with empty name", async () => {
render(<AgentsView addToast={mockAddToast} />);

View File

@@ -1508,6 +1508,14 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
});
it("shows agentOnboarding in the Experimental Features list", async () => {
renderModal();
await openExperimentalFeaturesSection();
expect(screen.getByLabelText("Planning-style Agent Onboarding")).toBeInTheDocument();
});
it("shows a single canonical Dev Server toggle", async () => {
renderModal();

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import {
parseAgentOnboardingResponse,
createAgentOnboardingSessionPrompt,
} from "../agent-onboarding.js";
describe("agent-onboarding", () => {
it("parses question responses", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "question",
data: {
id: "q1",
type: "text",
question: "What should this agent focus on?",
},
}),
);
expect(parsed.type).toBe("question");
if (parsed.type === "question") {
expect(parsed.data.id).toBe("q1");
}
});
it("parses complete summary responses", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Docs Reviewer",
role: "reviewer",
instructionsText: "Review docs for clarity and accuracy.",
thinkingLevel: "medium",
maxTurns: 20,
},
}),
);
expect(parsed.type).toBe("complete");
if (parsed.type === "complete") {
expect(parsed.data.name).toBe("Docs Reviewer");
expect(parsed.data.maxTurns).toBe(20);
}
});
it("rejects invalid complete summary", () => {
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "",
role: "reviewer",
instructionsText: "",
thinkingLevel: "medium",
maxTurns: 0,
},
}),
),
).toThrow(/Invalid summary/);
});
it("builds compact onboarding context prompt", () => {
const prompt = createAgentOnboardingSessionPrompt({
intent: "Need a reviewer for docs",
existingAgents: [{ id: "a1", name: "Alpha", role: "reviewer" }],
templates: [{ id: "t1", label: "Reviewer Template", description: "General reviewer" }],
});
expect(prompt).toContain("Need a reviewer for docs");
expect(prompt).toContain("a1:Alpha(reviewer)");
expect(prompt).toContain("t1:Reviewer Template");
});
});

View File

@@ -0,0 +1,335 @@
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AgentCapability, PlanningQuestion } from "@fusion/core";
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
export interface AgentOnboardingSummary {
name: string;
role: AgentCapability | "custom";
instructionsText: string;
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
maxTurns: number;
title?: string;
icon?: string;
reportsTo?: string;
soul?: string;
memory?: string;
skills?: string[];
templateId?: string;
patternAgentId?: string;
rationale?: string;
}
export type AgentOnboardingStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: AgentOnboardingSummary }
| { type: "error"; data: string }
| { type: "complete" };
export type AgentOnboardingStreamCallback = (event: AgentOnboardingStreamEvent, eventId?: number) => void;
const createFnAgent: any = engineCreateFnAgent;
const SESSION_TTL_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
const GENERATION_TIMEOUT_MS = 120_000;
export const AGENT_ONBOARDING_SYSTEM_PROMPT = `You are an agent onboarding assistant for the fn task board system.
Your job is to guide users through creating a new agent with a short interview.
Use the provided context (existing agents + template options) to make concrete suggestions.
Ask targeted questions using this JSON format:
{"type":"question","data":{"id":"q1","type":"text|single_select|multi_select|confirm","question":"...","description":"...","options":[{"id":"x","label":"X","description":"..."}]}}
When ready, return a final summary JSON in this exact format:
{"type":"complete","data":{"name":"...","role":"executor","instructionsText":"...","thinkingLevel":"medium","maxTurns":25,"title":"...","icon":"🤖","reportsTo":"...","soul":"...","memory":"...","skills":["..."],"templateId":"...","patternAgentId":"...","rationale":"..."}}
Rules:
- role must be one of triage|executor|reviewer|merger|scheduler|engineer|custom
- thinkingLevel must be off|minimal|low|medium|high
- maxTurns must be a positive integer
- Do not include runtimeMode/model/runtimeHint; those are user review-time choices.`;
interface Session {
id: string;
ip: string;
contextPrompt: string;
currentQuestion?: PlanningQuestion;
summary?: AgentOnboardingSummary;
error?: string;
history: Array<{ question: PlanningQuestion; response: Record<string, unknown> }>;
thinkingOutput: string;
agent?: any;
createdAt: Date;
updatedAt: Date;
}
const sessions = new Map<string, Session>();
const activeGenerations = new Map<string, { abortController: AbortController; timer: NodeJS.Timeout }>();
export class AgentOnboardingStreamManager extends EventEmitter {
private readonly sessions = new Map<string, Set<AgentOnboardingStreamCallback>>();
private readonly buffers = new Map<string, SessionEventBuffer>();
subscribe(sessionId: string, callback: AgentOnboardingStreamCallback): () => void {
if (!this.sessions.has(sessionId)) this.sessions.set(sessionId, new Set());
const callbacks = this.sessions.get(sessionId)!;
callbacks.add(callback);
return () => {
callbacks.delete(callback);
if (callbacks.size === 0) this.sessions.delete(sessionId);
};
}
private getBuffer(sessionId: string): SessionEventBuffer {
let buffer = this.buffers.get(sessionId);
if (!buffer) {
buffer = new SessionEventBuffer(100);
this.buffers.set(sessionId, buffer);
}
return buffer;
}
broadcast(sessionId: string, event: AgentOnboardingStreamEvent): number {
const serialized = JSON.stringify((event as { data?: unknown }).data ?? {});
const eventId = this.getBuffer(sessionId).push(event.type, serialized);
const callbacks = this.sessions.get(sessionId);
if (!callbacks) return eventId;
for (const callback of callbacks) callback(event, eventId);
return eventId;
}
getBufferedEvents(sessionId: string, sinceId: number): SessionBufferedEvent[] {
const buffer = this.buffers.get(sessionId);
if (!buffer) return [];
return buffer.getEventsSince(sinceId);
}
cleanupSession(sessionId: string): void {
this.sessions.delete(sessionId);
this.buffers.delete(sessionId);
}
}
export const agentOnboardingStreamManager = new AgentOnboardingStreamManager();
function extractJsonCandidate(text: string): string | null {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) return codeBlockMatch[1].trim();
const first = text.indexOf("{");
const last = text.lastIndexOf("}");
if (first >= 0 && last > first) return text.slice(first, last + 1);
return text.trim().startsWith("{") ? text.trim() : null;
}
function repairJson(text: string): string {
return text.replace(/,\s*([}\]])/g, "$1");
}
export function parseAgentOnboardingResponse(text: string): { type: "question"; data: PlanningQuestion } | { type: "complete"; data: AgentOnboardingSummary } {
const candidate = extractJsonCandidate(text);
if (!candidate) throw new Error("AI returned no valid JSON");
let parsed: any;
try {
parsed = JSON.parse(candidate);
} catch {
parsed = JSON.parse(repairJson(candidate));
}
if (!parsed || (parsed.type !== "question" && parsed.type !== "complete")) {
throw new Error("AI returned invalid response type");
}
if (parsed.type === "complete") {
const data = parsed.data ?? {};
if (typeof data.name !== "string" || !data.name.trim()) throw new Error("Invalid summary.name");
if (typeof data.instructionsText !== "string" || !data.instructionsText.trim()) throw new Error("Invalid summary.instructionsText");
if (!Number.isInteger(data.maxTurns) || data.maxTurns <= 0) throw new Error("Invalid summary.maxTurns");
}
return parsed;
}
export function createAgentOnboardingSessionPrompt(input: {
intent: string;
existingAgents: Array<{ id: string; name: string; role: string }>;
templates: Array<{ id: string; label: string; description?: string }>;
}): 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}`;
}
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 }> },
rootDir: string,
modelProvider?: string,
modelId?: string,
promptOverrides?: PromptOverrideMap,
): Promise<string> {
const id = randomUUID();
const session: Session = {
id,
ip,
contextPrompt: createAgentOnboardingSessionPrompt(initialContext),
history: [],
thinkingOutput: "",
createdAt: new Date(),
updatedAt: new Date(),
};
sessions.set(id, session);
const systemPrompt = resolvePrompt("agent-onboarding-system", promptOverrides) || AGENT_ONBOARDING_SYSTEM_PROMPT;
session.agent = await createFnAgent({
cwd: rootDir,
systemPrompt,
tools: "readonly",
...(modelProvider && modelId ? { defaultProvider: modelProvider, defaultModelId: modelId } : {}),
onThinking: (delta: string) => {
session.thinkingOutput += delta;
agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta });
},
onText: (delta: string) => {
session.thinkingOutput += delta;
agentOnboardingStreamManager.broadcast(session.id, { type: "thinking", data: delta });
},
});
void continueConversation(session, session.contextPrompt);
return id;
}
async function runGenerationWithTimeout<T>(session: Session, operation: () => Promise<T>): Promise<T> {
const existing = activeGenerations.get(session.id);
if (existing) {
clearTimeout(existing.timer);
existing.abortController.abort();
}
const abortController = new AbortController();
const timer = setTimeout(() => {
session.error = "AI generation timed out. You can retry.";
agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error });
abortController.abort();
}, GENERATION_TIMEOUT_MS);
activeGenerations.set(session.id, { abortController, timer });
try {
return await operation();
} finally {
clearTimeout(timer);
activeGenerations.delete(session.id);
}
}
async function continueConversation(session: Session, message: string): Promise<void> {
if (!session.agent) throw new Error("Session agent not initialized");
session.thinkingOutput = "";
try {
await runGenerationWithTimeout(session, async () => {
await session.agent.session.prompt(message);
const assistant = (session.agent.session.state.messages as Array<{ role: string; content?: string | Array<{ type: string; text: string }> }>).filter((m) => m.role === "assistant").pop();
let responseText = session.thinkingOutput;
if (assistant?.content) {
if (typeof assistant.content === "string") responseText = assistant.content;
else responseText = assistant.content.filter((c) => c.type === "text").map((c) => c.text).join("");
}
const parsed = parseAgentOnboardingResponse(responseText);
session.error = undefined;
session.updatedAt = new Date();
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
agentOnboardingStreamManager.broadcast(session.id, { type: "question", data: parsed.data });
} else {
session.summary = parsed.data;
session.currentQuestion = undefined;
agentOnboardingStreamManager.broadcast(session.id, { type: "summary", data: parsed.data });
agentOnboardingStreamManager.broadcast(session.id, { type: "complete" });
}
});
} catch (err) {
session.error = err instanceof Error ? err.message : String(err);
agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error });
}
}
export async function respondToAgentOnboarding(sessionId: string, responses: Record<string, unknown>): Promise<void> {
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
if (!session.currentQuestion) throw new InvalidSessionStateError("No active question in session");
session.history.push({ question: session.currentQuestion, response: responses });
const formatted = `Question: ${session.currentQuestion.question}\nAnswer: ${JSON.stringify(responses)}`;
await continueConversation(session, formatted);
}
export async function retryAgentOnboardingSession(sessionId: string): Promise<void> {
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
if (!session.error) throw new InvalidSessionStateError("Session is not in an error state");
session.error = undefined;
const retryPrompt = session.currentQuestion
? `Please continue from the last question: ${session.currentQuestion.question}`
: "Please continue and ask the next best onboarding question.";
await continueConversation(session, retryPrompt);
}
export function stopAgentOnboardingGeneration(sessionId: string): boolean {
const active = activeGenerations.get(sessionId);
if (!active) return false;
clearTimeout(active.timer);
active.abortController.abort();
activeGenerations.delete(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.error = "Generation stopped by user. You can retry.";
agentOnboardingStreamManager.broadcast(session.id, { type: "error", data: session.error });
}
return true;
}
export async function cancelAgentOnboardingSession(sessionId: string): Promise<void> {
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Agent onboarding session ${sessionId} not found or expired`);
stopAgentOnboardingGeneration(sessionId);
try { session.agent?.session.dispose?.(); } catch {}
sessions.delete(sessionId);
agentOnboardingStreamManager.cleanupSession(sessionId);
}
export function getAgentOnboardingSession(sessionId: string): Session | undefined {
return sessions.get(sessionId);
}
export function getAgentOnboardingSummary(sessionId: string): AgentOnboardingSummary | undefined {
return sessions.get(sessionId)?.summary;
}
export function __resetAgentOnboardingState(): void {
for (const sessionId of sessions.keys()) {
void cancelAgentOnboardingSession(sessionId).catch(() => {});
}
sessions.clear();
activeGenerations.clear();
}
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
void cancelAgentOnboardingSession(id).catch(() => {});
}
}
}, CLEANUP_INTERVAL_MS).unref?.();
export class SessionNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionNotFoundError";
}
}
export class InvalidSessionStateError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidSessionStateError";
}
}

View File

@@ -6,6 +6,7 @@ import { Readable } from "node:stream";
import { pipeline as streamPipeline } from "node:stream/promises";
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
import { createSessionDiagnostics } from "../ai-session-diagnostics.js";
import { writeSSEEvent } from "../sse-buffer.js";
import {
startAgentGeneration,
generateAgentSpec,
@@ -794,6 +795,166 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const agentGenerationDiagnostics = createSessionDiagnostics("agent-generation");
router.post("/agents/onboarding/start-streaming", async (req, res) => {
try {
const { intent, context, planningModelProvider, planningModelId } = req.body as {
intent?: string;
context?: {
existingAgents?: Array<{ id: string; name: string; role: string }>;
templates?: Array<{ id: string; label: string; description?: string }>;
};
planningModelProvider?: string;
planningModelId?: string;
};
if (!intent || typeof intent !== "string") {
throw badRequest("intent is required and must be a string");
}
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const { startAgentOnboardingSession } = await import("../agent-onboarding.js");
const sessionId = await startAgentOnboardingSession(
ip,
{
intent,
existingAgents: Array.isArray(context?.existingAgents) ? context.existingAgents : [],
templates: Array.isArray(context?.templates) ? context.templates : [],
},
scopedStore.getRootDir(),
planningModelProvider,
planningModelId,
settings.promptOverrides,
);
res.status(201).json({ sessionId });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to start agent onboarding session");
}
});
router.get("/agents/onboarding/:sessionId/stream", async (req, res) => {
const { sessionId } = req.params;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
res.write(": connected\n\n");
const { agentOnboardingStreamManager, getAgentOnboardingSession } = await import("../agent-onboarding.js");
const session = getAgentOnboardingSession(sessionId);
if (!session) {
writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" }));
res.end();
return;
}
if (session.summary) {
writeSSEEvent(res, "summary", JSON.stringify(session.summary));
writeSSEEvent(res, "complete", JSON.stringify({}));
res.end();
return;
}
if (session.currentQuestion) {
writeSSEEvent(res, "question", JSON.stringify(session.currentQuestion));
}
const unsubscribe = agentOnboardingStreamManager.subscribe(sessionId, (event, eventId) => {
const data = (event as { data?: unknown }).data;
if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) {
unsubscribe();
return;
}
if (event.type === "complete" || event.type === "error") {
unsubscribe();
res.end();
}
});
const heartbeat = setInterval(() => {
if (res.writableEnded) {
clearInterval(heartbeat);
return;
}
res.write(": heartbeat\n\n");
}, 30_000);
req.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
});
router.post("/agents/onboarding/respond", async (req, res) => {
try {
const { sessionId, responses } = req.body as { sessionId?: string; responses?: Record<string, unknown> };
if (!sessionId || typeof sessionId !== "string") throw badRequest("sessionId is required");
if (!responses || typeof responses !== "object") throw badRequest("responses is required and must be an object");
const { respondToAgentOnboarding, getAgentOnboardingSummary, getAgentOnboardingSession } = await import("../agent-onboarding.js");
await respondToAgentOnboarding(sessionId, responses);
const summary = getAgentOnboardingSummary(sessionId);
if (summary) {
res.json({ type: "complete", data: summary });
return;
}
const session = getAgentOnboardingSession(sessionId);
if (!session?.currentQuestion) throw badRequest("Session did not produce a question");
res.json({ type: "question", data: session.currentQuestion });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if (err instanceof Error && err.name === "SessionNotFoundError") throw notFound(err.message);
if (err instanceof Error && err.name === "InvalidSessionStateError") throw badRequest(err.message);
rethrowAsApiError(err, "Failed to process agent onboarding response");
}
});
router.post("/agents/onboarding/:sessionId/retry", async (req, res) => {
try {
const { sessionId } = req.params;
const { retryAgentOnboardingSession } = await import("../agent-onboarding.js");
await retryAgentOnboardingSession(sessionId);
res.json({ success: true, sessionId });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if (err instanceof Error && err.name === "SessionNotFoundError") throw notFound(err.message);
if (err instanceof Error && err.name === "InvalidSessionStateError") throw badRequest(err.message);
rethrowAsApiError(err, "Failed to retry agent onboarding session");
}
});
router.post("/agents/onboarding/:sessionId/stop", async (req, res) => {
try {
const { sessionId } = req.params;
const { stopAgentOnboardingGeneration } = await import("../agent-onboarding.js");
const stopped = stopAgentOnboardingGeneration(sessionId);
if (!stopped) throw notFound(`Agent onboarding session ${sessionId} not found or inactive`);
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to stop agent onboarding generation");
}
});
router.post("/agents/onboarding/cancel", async (req, res) => {
try {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId || typeof sessionId !== "string") throw badRequest("sessionId is required");
const { cancelAgentOnboardingSession } = await import("../agent-onboarding.js");
await cancelAgentOnboardingSession(sessionId);
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if (err instanceof Error && err.name === "SessionNotFoundError") throw notFound(err.message);
rethrowAsApiError(err, "Failed to cancel agent onboarding session");
}
});
/**
* POST /api/agents/generate/start
* Start a new agent generation session.