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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
22
packages/dashboard/app/components/AgentOnboardingModal.css
Normal file
22
packages/dashboard/app/components/AgentOnboardingModal.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
262
packages/dashboard/app/components/AgentOnboardingModal.tsx
Normal file
262
packages/dashboard/app/components/AgentOnboardingModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
75
packages/dashboard/src/__tests__/agent-onboarding.test.ts
Normal file
75
packages/dashboard/src/__tests__/agent-onboarding.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
335
packages/dashboard/src/agent-onboarding.ts
Normal file
335
packages/dashboard/src/agent-onboarding.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user