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:
@@ -82,13 +82,14 @@ describe("prompt-overrides", () => {
|
|||||||
describe("getPromptKeysForRole", () => {
|
describe("getPromptKeysForRole", () => {
|
||||||
it("should return all keys for executor role", () => {
|
it("should return all keys for executor role", () => {
|
||||||
const keys = getPromptKeysForRole("executor");
|
const keys = getPromptKeysForRole("executor");
|
||||||
expect(keys).toHaveLength(8);
|
expect(keys).toHaveLength(9);
|
||||||
expect(keys.map((k) => k.key)).toContain("executor-welcome");
|
expect(keys.map((k) => k.key)).toContain("executor-welcome");
|
||||||
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
|
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
|
||||||
expect(keys.map((k) => k.key)).toContain("executor-spawning");
|
expect(keys.map((k) => k.key)).toContain("executor-spawning");
|
||||||
expect(keys.map((k) => k.key)).toContain("executor-completion");
|
expect(keys.map((k) => k.key)).toContain("executor-completion");
|
||||||
expect(keys.map((k) => k.key)).toContain("agent-generation-system");
|
expect(keys.map((k) => k.key)).toContain("agent-generation-system");
|
||||||
expect(keys.map((k) => k.key)).toContain("workflow-step-refine");
|
expect(keys.map((k) => k.key)).toContain("workflow-step-refine");
|
||||||
|
expect(keys.map((k) => k.key)).toContain("agent-onboarding-system");
|
||||||
expect(keys.map((k) => k.key)).toContain("subtask-breakdown-system");
|
expect(keys.map((k) => k.key)).toContain("subtask-breakdown-system");
|
||||||
expect(keys.map((k) => k.key)).toContain("ai-refine-system");
|
expect(keys.map((k) => k.key)).toContain("ai-refine-system");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type PromptKey =
|
|||||||
| "agent-generation-system"
|
| "agent-generation-system"
|
||||||
| "workflow-step-refine"
|
| "workflow-step-refine"
|
||||||
| "planning-system"
|
| "planning-system"
|
||||||
|
| "agent-onboarding-system"
|
||||||
| "subtask-breakdown-system"
|
| "subtask-breakdown-system"
|
||||||
| "mission-interview-system"
|
| "mission-interview-system"
|
||||||
| "ai-refine-system";
|
| "ai-refine-system";
|
||||||
@@ -299,6 +300,28 @@ For questions:
|
|||||||
|
|
||||||
For completion:
|
For completion:
|
||||||
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`,
|
{\n "type": "complete",\n "data": {\n "title": "Task title",\n "description": "Detailed description",\n "suggestedSize": "S|M|L",\n "suggestedDependencies": [],\n "keyDeliverables": ["Item 1", "Item 2"]\n }\n}`,
|
||||||
|
},
|
||||||
|
"agent-onboarding-system": {
|
||||||
|
key: "agent-onboarding-system",
|
||||||
|
name: "Agent Onboarding System",
|
||||||
|
roles: ["executor"],
|
||||||
|
description: "System prompt for the AI onboarding assistant that interactively builds new agent configurations",
|
||||||
|
defaultContent: `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.`,
|
||||||
},
|
},
|
||||||
"subtask-breakdown-system": {
|
"subtask-breakdown-system": {
|
||||||
key: "subtask-breakdown-system",
|
key: "subtask-breakdown-system",
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ function AppInner() {
|
|||||||
const skillsEnabled = experimentalFeatures.skillsView === true;
|
const skillsEnabled = experimentalFeatures.skillsView === true;
|
||||||
const nodesEnabled = experimentalFeatures.nodesView === true;
|
const nodesEnabled = experimentalFeatures.nodesView === true;
|
||||||
const researchEnabled = experimentalFeatures.researchView === true;
|
const researchEnabled = experimentalFeatures.researchView === true;
|
||||||
|
const agentOnboardingEnabled = experimentalFeatures.agentOnboarding === true;
|
||||||
const agentsEnabled = true;
|
const agentsEnabled = true;
|
||||||
|
|
||||||
// Redirect to board if feature-gated views are disabled.
|
// Redirect to board if feature-gated views are disabled.
|
||||||
@@ -688,7 +689,12 @@ function AppInner() {
|
|||||||
return (
|
return (
|
||||||
<PageErrorBoundary>
|
<PageErrorBoundary>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AgentsView addToast={addToast} projectId={currentProject?.id} onOpenTaskLogs={handleOpenTaskLogs} />
|
<AgentsView
|
||||||
|
addToast={addToast}
|
||||||
|
projectId={currentProject?.id}
|
||||||
|
onOpenTaskLogs={handleOpenTaskLogs}
|
||||||
|
agentOnboardingEnabled={agentOnboardingEnabled}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PageErrorBoundary>
|
</PageErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import {
|
|||||||
saveWorkspaceFileContent,
|
saveWorkspaceFileContent,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
startPlanningStreaming,
|
startPlanningStreaming,
|
||||||
|
startAgentOnboardingStreaming,
|
||||||
|
respondToAgentOnboarding,
|
||||||
|
retryAgentOnboardingSession,
|
||||||
|
stopAgentOnboardingGeneration,
|
||||||
|
cancelAgentOnboarding,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
@@ -6276,3 +6281,58 @@ describe("updatePiExtensions", () => {
|
|||||||
await expect(updatePiExtensions(["ext-1"])).rejects.toThrow();
|
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: "error"; data: string }
|
||||||
| { type: "complete"; data: Record<string, never> };
|
| { 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 */
|
/** Start a new planning session with an initial plan */
|
||||||
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
|
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
|
||||||
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
|
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 */
|
/** Create a task from a completed planning session */
|
||||||
export function createTaskFromPlanning(
|
export function createTaskFromPlanning(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -3613,6 +3687,63 @@ export function getPlanningStreamUrl(sessionId: string, projectId?: string): str
|
|||||||
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
|
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
|
/** Connect to planning session SSE stream and handle events
|
||||||
*
|
*
|
||||||
* Returns an object with:
|
* 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 { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||||
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
|
import { AgentOnboardingModal } from "./AgentOnboardingModal";
|
||||||
import { AgentImportModal } from "./AgentImportModal";
|
import { AgentImportModal } from "./AgentImportModal";
|
||||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
import { getAgentHealthStatus } from "../utils/agentHealth";
|
import { getAgentHealthStatus } from "../utils/agentHealth";
|
||||||
@@ -31,6 +32,7 @@ export interface AgentsViewProps {
|
|||||||
addToast: (message: string, type?: "success" | "error") => void;
|
addToast: (message: string, type?: "success" | "error") => void;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onOpenTaskLogs?: (taskId: string) => void;
|
onOpenTaskLogs?: (taskId: string) => void;
|
||||||
|
agentOnboardingEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
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 [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||||
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
|
const { agents, stats, isLoading, loadAgents } = useAgents(projectId, {
|
||||||
@@ -266,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
showSystemAgents,
|
showSystemAgents,
|
||||||
});
|
});
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||||
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
|
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 showInitialAgentsLoading = isLoading && agents.length === 0;
|
||||||
|
|
||||||
|
const handleOpenNewAgent = useCallback(() => {
|
||||||
|
if (agentOnboardingEnabled) {
|
||||||
|
setIsOnboardingOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreating(true);
|
||||||
|
}, [agentOnboardingEnabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agents-view">
|
<div className="agents-view">
|
||||||
<div className="agents-view-header">
|
<div className="agents-view-header">
|
||||||
@@ -785,7 +796,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
<button
|
<button
|
||||||
className="btn btn-task-create btn-sm"
|
className="btn btn-task-create btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCreating(true);
|
handleOpenNewAgent();
|
||||||
setIsControlsPanelOpen(false);
|
setIsControlsPanelOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -915,6 +926,18 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AgentOnboardingModal
|
||||||
|
isOpen={isOnboardingOpen}
|
||||||
|
onClose={() => setIsOnboardingOpen(false)}
|
||||||
|
onCreated={() => {
|
||||||
|
setIsOnboardingOpen(false);
|
||||||
|
void loadAgents();
|
||||||
|
}}
|
||||||
|
addToast={addToast}
|
||||||
|
projectId={projectId}
|
||||||
|
existingAgents={agents}
|
||||||
|
/>
|
||||||
|
|
||||||
<AgentImportModal
|
<AgentImportModal
|
||||||
isOpen={isImporting}
|
isOpen={isImporting}
|
||||||
onClose={() => setIsImporting(false)}
|
onClose={() => setIsImporting(false)}
|
||||||
@@ -931,7 +954,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
) : agentView === "tree" ? (
|
) : agentView === "tree" ? (
|
||||||
<div className="agent-tree__view">
|
<div className="agent-tree__view">
|
||||||
{displayAgents.length === 0 ? (
|
{displayAgents.length === 0 ? (
|
||||||
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
|
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||||
) : (
|
) : (
|
||||||
hierarchy.rootNodes.map((node) => (
|
hierarchy.rootNodes.map((node) => (
|
||||||
<AgentTreeNode
|
<AgentTreeNode
|
||||||
@@ -956,7 +979,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
<span>Loading org chart...</span>
|
<span>Loading org chart...</span>
|
||||||
</div>
|
</div>
|
||||||
) : displayOrgTree.length === 0 ? (
|
) : displayOrgTree.length === 0 ? (
|
||||||
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
|
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||||
) : (
|
) : (
|
||||||
displayOrgTree.map((node) => (
|
displayOrgTree.map((node) => (
|
||||||
<OrgChartNode
|
<OrgChartNode
|
||||||
@@ -973,7 +996,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
) : agentView === "board" ? (
|
) : agentView === "board" ? (
|
||||||
<div className="agent-board">
|
<div className="agent-board">
|
||||||
{displayAgents.length === 0 ? (
|
{displayAgents.length === 0 ? (
|
||||||
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
|
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||||
) : (
|
) : (
|
||||||
displayAgents.map((agent) => {
|
displayAgents.map((agent) => {
|
||||||
const health = getHealthStatus(agent);
|
const health = getHealthStatus(agent);
|
||||||
@@ -1007,7 +1030,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs }: AgentsViewPr
|
|||||||
) : (
|
) : (
|
||||||
<div className="agent-list">
|
<div className="agent-list">
|
||||||
{displayAgents.length === 0 ? (
|
{displayAgents.length === 0 ? (
|
||||||
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
|
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||||
) : (
|
) : (
|
||||||
// List view: detailed card layout
|
// List view: detailed card layout
|
||||||
displayAgents.map(agent => {
|
displayAgents.map(agent => {
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
|
|||||||
devServerView: "Dev Server",
|
devServerView: "Dev Server",
|
||||||
todoView: "Todo List",
|
todoView: "Todo List",
|
||||||
researchView: "Research View",
|
researchView: "Research View",
|
||||||
|
agentOnboarding: "Planning-style Agent Onboarding",
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXPERIMENTAL_FEATURE_LEGACY_ALIASES: Record<string, string> = {
|
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: [] }),
|
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||||
fetchPluginRuntimes: vi.fn().mockResolvedValue([]),
|
fetchPluginRuntimes: vi.fn().mockResolvedValue([]),
|
||||||
fetchDiscoveredSkills: 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", () => ({
|
vi.mock("../AgentDetailView", () => ({
|
||||||
@@ -1147,6 +1152,35 @@ describe("AgentsView", () => {
|
|||||||
expect(screen.getByPlaceholderText("e.g. Frontend Reviewer")).toBeTruthy();
|
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 () => {
|
it("does not allow proceeding with empty name", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1508,6 +1508,14 @@ describe("SettingsModal", () => {
|
|||||||
expect(screen.getByLabelText("Research View")).toBeInTheDocument();
|
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 () => {
|
it("shows a single canonical Dev Server toggle", async () => {
|
||||||
renderModal();
|
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 { pipeline as streamPipeline } from "node:stream/promises";
|
||||||
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
|
import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js";
|
||||||
import { createSessionDiagnostics } from "../ai-session-diagnostics.js";
|
import { createSessionDiagnostics } from "../ai-session-diagnostics.js";
|
||||||
|
import { writeSSEEvent } from "../sse-buffer.js";
|
||||||
import {
|
import {
|
||||||
startAgentGeneration,
|
startAgentGeneration,
|
||||||
generateAgentSpec,
|
generateAgentSpec,
|
||||||
@@ -794,6 +795,166 @@ export function registerAgentGenerationRoutes(ctx: ApiRoutesContext): void {
|
|||||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||||
const agentGenerationDiagnostics = createSessionDiagnostics("agent-generation");
|
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
|
* POST /api/agents/generate/start
|
||||||
* Start a new agent generation session.
|
* Start a new agent generation session.
|
||||||
|
|||||||
Reference in New Issue
Block a user