Files
fusion/packages/dashboard/src/__tests__/agent-onboarding.test.ts
Fusion e60c8c3c5a feat(FN-3555): document richer onboarding drafts in agents.md and dashboard
Documents richer onboarding drafts in agents.md and updates the dashboard README with a small addition, completing Step 4 of the onboarding feature.

Fusion-Task-Id: FN-3555
2026-05-07 11:25:01 -07:00

396 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockCreateFnAgent } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
}));
vi.mock("@fusion/engine", () => ({
createFnAgent: mockCreateFnAgent,
}));
import {
__resetAgentOnboardingState,
cancelAgentOnboardingSession,
createAgentOnboardingSessionPrompt,
getAgentOnboardingSession,
getAgentOnboardingSummary,
InvalidSessionStateError,
parseAgentOnboardingResponse,
respondToAgentOnboarding,
SessionNotFoundError,
startAgentOnboardingSession,
} from "../agent-onboarding.js";
function createMockAgent(responses: string[]) {
const queue = [...responses];
const messages: Array<{ role: string; content: string }> = [];
return {
session: {
state: { messages },
prompt: vi.fn(async () => {
const response = queue.shift() ?? queue[queue.length - 1] ?? "{}";
messages.push({ role: "assistant", content: response });
}),
dispose: vi.fn(),
},
};
}
async function waitFor(check: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timed out waiting for condition");
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
describe("agent-onboarding", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetAgentOnboardingState();
});
afterEach(() => {
__resetAgentOnboardingState();
});
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 with rich optional draft fields", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Docs Reviewer",
role: "reviewer",
instructionsText: "Review docs for clarity and accuracy.",
thinkingLevel: "medium",
maxTurns: 20,
soul: "Calm and thorough",
memory: "Remember docs conventions",
heartbeatProcedurePath: " .fusion/agents/docs-reviewer/HEARTBEAT.md ",
heartbeatIntervalMs: 30000,
heartbeatEnabled: true,
modelHint: "anthropic/claude-sonnet-4-5",
runtimeHint: "openclaw",
},
}),
);
expect(parsed.type).toBe("complete");
if (parsed.type === "complete") {
expect(parsed.data.name).toBe("Docs Reviewer");
expect(parsed.data.maxTurns).toBe(20);
expect(parsed.data.heartbeatProcedurePath).toBe(".fusion/agents/docs-reviewer/HEARTBEAT.md");
expect(parsed.data.heartbeatIntervalMs).toBe(30000);
expect(parsed.data.heartbeatEnabled).toBe(true);
expect(parsed.data.modelHint).toBe("anthropic/claude-sonnet-4-5");
expect(parsed.data.runtimeHint).toBe("openclaw");
}
});
it("parses legacy complete summaries without rich draft fields", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Legacy Reviewer",
role: "reviewer",
instructionsText: "Review old style drafts",
thinkingLevel: "low",
maxTurns: 10,
},
}),
);
expect(parsed.type).toBe("complete");
if (parsed.type === "complete") {
expect(parsed.data.name).toBe("Legacy Reviewer");
expect(parsed.data.heartbeatProcedurePath).toBeUndefined();
expect(parsed.data.modelHint).toBeUndefined();
expect(parsed.data.runtimeHint).toBeUndefined();
}
});
it("rejects invalid complete summary", () => {
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "",
role: "reviewer",
instructionsText: "",
thinkingLevel: "medium",
maxTurns: 0,
},
}),
),
).toThrow(/Invalid summary/);
});
it("rejects malformed rich draft fields", () => {
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatProcedurePath: "",
},
}),
),
).toThrow("Invalid summary.heartbeatProcedurePath");
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatIntervalMs: 0,
},
}),
),
).toThrow("Invalid summary.heartbeatIntervalMs");
expect(() =>
parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
data: {
name: "Malformed",
role: "reviewer",
instructionsText: "Valid instructions",
thinkingLevel: "medium",
maxTurns: 20,
heartbeatEnabled: "yes",
modelHint: 10,
runtimeHint: { runtime: "openclaw" },
},
}),
),
).toThrow(/Invalid summary\.(heartbeatEnabled|modelHint|runtimeHint)/);
});
it("builds compact onboarding context prompt for create mode", () => {
const prompt = createAgentOnboardingSessionPrompt({
mode: "create",
intent: "Need a reviewer for docs",
existingAgents: [{ id: "a1", name: "Alpha", role: "reviewer" }],
templates: [{ id: "t1", label: "Reviewer Template", description: "General reviewer" }],
});
expect(prompt).toContain("Need a reviewer for docs");
expect(prompt).toContain("a1:Alpha(reviewer)");
expect(prompt).toContain("t1:Reviewer Template");
expect(prompt).not.toContain("Current agent configuration:");
});
it("appends current agent configuration in edit mode prompt", () => {
const prompt = createAgentOnboardingSessionPrompt({
mode: "edit",
intent: "Improve this agent",
existingAgents: [{ id: "a1", name: "Alpha", role: "reviewer" }],
templates: [{ id: "t1", label: "Reviewer Template", description: "General reviewer" }],
existingAgentConfig: {
name: "Alpha",
role: "reviewer",
title: "Senior Reviewer",
instructionsText: "Current instructions",
soul: "Calm",
memory: "Team context",
reportsTo: "mgr-1",
skills: ["linting"],
model: "openai/gpt-5-mini",
thinkingLevel: "low",
maxTurns: 40,
runtimeHint: "gpu",
heartbeatIntervalMs: 30000,
heartbeatTimeoutMs: 120000,
maxConcurrentRuns: 2,
messageResponseMode: "immediate",
},
});
expect(prompt).toContain("Current agent configuration:");
expect(prompt).toContain("name: Alpha");
expect(prompt).toContain("instructionsText: Current instructions");
expect(prompt).toContain("messageResponseMode: immediate");
});
it("progresses through start -> question -> response -> final summary", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "goal", type: "text", question: "What is the primary goal?" },
}),
JSON.stringify({
type: "complete",
data: {
name: "Repo Steward",
role: "engineer",
instructionsText: "Keep the repo healthy and triage drift.",
thinkingLevel: "low",
maxTurns: 18,
templateId: "eng-template",
rationale: "Template path selected",
},
}),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{
intent: "I need an engineer for repository hygiene",
existingAgents: [{ id: "agent-1", name: "Alpha", role: "engineer" }],
templates: [{ id: "eng-template", label: "Engineer template" }],
},
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion));
const session = getAgentOnboardingSession(sessionId);
expect(session?.currentQuestion?.id).toBe("goal");
await respondToAgentOnboarding(sessionId, { goal: "Keep CI green" });
await waitFor(() => Boolean(getAgentOnboardingSummary(sessionId)));
const summary = getAgentOnboardingSummary(sessionId);
expect(summary?.name).toBe("Repo Steward");
expect(summary?.templateId).toBe("eng-template");
});
it("defaults onboarding sessions to create mode", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q1", type: "text", question: "What should this agent do?" },
}),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{ intent: "create", existingAgents: [], templates: [] },
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)));
expect(getAgentOnboardingSession(sessionId)?.mode).toBe("create");
});
it("stores edit mode sessions and includes current config in agent prompt", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q1", type: "text", question: "What should change?" },
}),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{
mode: "edit",
intent: "Improve this agent",
existingAgents: [],
templates: [],
existingAgentConfig: {
name: "Editor",
instructionsText: "Current instructions",
messageResponseMode: "on-heartbeat",
},
},
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion));
expect(getAgentOnboardingSession(sessionId)?.mode).toBe("edit");
expect(getAgentOnboardingSession(sessionId)?.contextPrompt).toContain("Current agent configuration:");
expect(getAgentOnboardingSession(sessionId)?.contextPrompt).toContain("name: Editor");
});
it("throws InvalidSessionStateError when responding without an active question", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({
type: "complete",
data: {
name: "Direct Summary",
role: "reviewer",
instructionsText: "Review with no follow-up",
thinkingLevel: "minimal",
maxTurns: 12,
},
}),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{ intent: "quick", existingAgents: [], templates: [] },
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSummary(sessionId)));
await expect(respondToAgentOnboarding(sessionId, { followup: "anything" })).rejects.toBeInstanceOf(
InvalidSessionStateError,
);
});
it("cancels session and subsequent access fails", async () => {
mockCreateFnAgent.mockResolvedValueOnce(
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q1", type: "text", question: "Question before cancel" },
}),
]),
);
const sessionId = await startAgentOnboardingSession(
"127.0.0.1",
{ intent: "cancel flow", existingAgents: [], templates: [] },
process.cwd(),
);
await waitFor(() => Boolean(getAgentOnboardingSession(sessionId)?.currentQuestion));
await cancelAgentOnboardingSession(sessionId);
expect(getAgentOnboardingSession(sessionId)).toBeUndefined();
await expect(respondToAgentOnboarding(sessionId, { q1: "x" })).rejects.toBeInstanceOf(SessionNotFoundError);
});
});