feat(FN-3559): cover launch flow and summary handoff (+1 more)

Merges agent onboarding test coverage (FN-3559: lock contract for modal/dialog flows, launch flow, summary handoff), adds heartbeat readonly gating with action-gate reload endpoints (FN-3734/FN-3730), fixes a service worker blank-page issue on first install, and documents dependency graph behaviors.

Fusion-Task-Id: FN-3559
This commit is contained in:
Fusion
2026-05-08 07:52:52 -07:00
committed by gsxdsm
parent ad34cb6c58
commit 50bb9d9cab
5 changed files with 193 additions and 2 deletions

View File

@@ -1767,7 +1767,7 @@ describe("agent onboarding API wrappers", () => {
globalThis.fetch = originalFetch;
});
it("starts onboarding streaming session with context payload", async () => {
it("starts onboarding streaming session with create context payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { sessionId: "onb-1" }, 201));
const result = await startAgentOnboardingStreaming(
@@ -1775,6 +1775,7 @@ describe("agent onboarding API wrappers", () => {
{
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
mode: "create",
},
"proj-123",
{ planningModelProvider: "openai", planningModelId: "gpt-4o" },
@@ -1789,13 +1790,67 @@ describe("agent onboarding API wrappers", () => {
context: {
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
mode: "create",
},
mode: "create",
planningModelProvider: "openai",
planningModelId: "gpt-4o",
}),
});
});
it("starts onboarding streaming session with edit context payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { sessionId: "onb-edit" }, 201));
const result = await startAgentOnboardingStreaming(
"Tighten this agent's review quality",
{
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
mode: "edit",
existingAgentConfig: {
name: "Reviewer",
role: "reviewer",
instructionsText: "Current instructions",
runtimeHint: "openclaw",
heartbeatIntervalMs: 30000,
},
},
"proj-123",
);
expect(result.sessionId).toBe("onb-edit");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/onboarding/start-streaming?projectId=proj-123",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
intent: "Tighten this agent's review quality",
context: {
existingAgents: [{ id: "agent-1", name: "Reviewer", role: "reviewer" }],
templates: [{ id: "preset-1", label: "Reviewer preset" }],
mode: "edit",
existingAgentConfig: {
name: "Reviewer",
role: "reviewer",
instructionsText: "Current instructions",
runtimeHint: "openclaw",
heartbeatIntervalMs: 30000,
},
},
mode: "edit",
existingAgentConfig: {
name: "Reviewer",
role: "reviewer",
instructionsText: "Current instructions",
runtimeHint: "openclaw",
heartbeatIntervalMs: 30000,
},
}),
}),
);
});
it("posts onboarding response/retry/stop/cancel endpoints", async () => {
globalThis.fetch = vi.fn()
.mockReturnValueOnce(mockFetchResponse(true, { type: "question", data: { id: "q1", type: "text", question: "?" } }))

View File

@@ -1884,6 +1884,22 @@ describe("AgentDetailView", () => {
expect((screen.getByLabelText("Role") as HTMLSelectElement).value).toBe("reviewer");
});
expect(mockUpdateAgent).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Save Settings" }));
await waitFor(() => {
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({
name: "Interviewed Agent",
role: "reviewer",
title: "Draft Title",
reportsTo: "agent-002",
runtimeConfig: expect.objectContaining({ model: "openai/gpt-4o" }),
metadata: { skills: ["skill-1"] },
}),
undefined,
);
});
});
it("shows settings delete control for idle and paused agents", async () => {

View File

@@ -30,6 +30,33 @@ vi.mock("../../api", async (importOriginal) => {
});
});
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => {
if (!isOpen) return null;
return (
<div role="dialog" aria-label="AI Interview">
<p>Draft ready for review</p>
<button type="button" onClick={onClose}>Cancel</button>
<button
type="button"
onClick={() =>
onUseDraft({
name: "Interview Draft Agent",
role: "reviewer",
title: "Drafted Title",
instructionsText: "Drafted instructions",
thinkingLevel: "low",
maxTurns: 10,
})
}
>
Apply draft to agent form
</button>
</div>
);
},
}));
vi.mock("../AgentDetailView", () => ({
AgentDetailView: ({ agentId, inline, onClose, showInlineBackButton, initialTab, initialRunId, preferActiveRun, onMutationSuccess }: { agentId: string; inline?: boolean; onClose?: () => void; showInlineBackButton?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean; onMutationSuccess?: (context: { agentId: string; deleted?: boolean }) => void | Promise<void> }) => (
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"} data-initial-tab={initialTab ?? "dashboard"} data-initial-run-id={initialRunId ?? ""} data-prefer-active-run={preferActiveRun ? "true" : "false"}>
@@ -1633,6 +1660,41 @@ describe("AgentsView", () => {
});
});
it("launches interview from AgentsView and only applies draft after review confirmation", async () => {
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={true} />);
await waitFor(() => {
expect(screen.getByText("New Agent")).toBeTruthy();
});
fireEvent.click(screen.getByText("New Agent"));
fireEvent.click(screen.getByRole("button", { name: "AI Interview" }));
const interviewDialog = await screen.findByRole("dialog", { name: "AI Interview" });
expect(screen.getByText("Draft ready for review")).toBeTruthy();
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(within(interviewDialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(screen.queryByRole("dialog", { name: "AI Interview" })).toBeNull();
});
expect(mockCreateAgent).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "AI Interview" }));
await screen.findByRole("dialog", { name: "AI Interview" });
fireEvent.click(screen.getByRole("button", { name: "Apply draft to agent form" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("tab", { name: "Custom agent" }));
const nameInput = screen.getByLabelText(/Name/) as HTMLInputElement;
expect(nameInput.value).toBe("Interview Draft Agent");
expect(mockCreateAgent).not.toHaveBeenCalled();
});
it("does not allow proceeding with empty name", async () => {
render(<AgentsView addToast={mockAddToast} />);

View File

@@ -48,6 +48,7 @@ const mockStartAgentOnboardingStreaming = vi.mocked(apiModule.startAgentOnboardi
describe("ExperimentalAgentOnboardingModal", () => {
beforeEach(() => {
vi.clearAllMocks();
streamHandlers = undefined;
});
it("renders draft review and only applies after explicit confirmation", async () => {
@@ -166,6 +167,35 @@ describe("ExperimentalAgentOnboardingModal", () => {
});
});
it("does not apply draft when cancelled from question step", async () => {
const onClose = vi.fn();
const onUseDraft = vi.fn();
render(
<ExperimentalAgentOnboardingModal
isOpen={true}
onClose={onClose}
onUseDraft={onUseDraft}
existingAgents={[]}
/>,
);
fireEvent.change(screen.getByLabelText("What should this new agent own?"), { target: { value: "Review docs" } });
fireEvent.click(screen.getByText("Start onboarding"));
await screen.findByText("What should this agent primarily help with?");
expect(screen.queryByText("Draft ready for review")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply draft to agent form" })).toBeNull();
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockCancel).toHaveBeenCalledWith("onb-1", undefined);
expect(onClose).toHaveBeenCalled();
expect(onUseDraft).not.toHaveBeenCalled();
});
});
it("cancels server session on close", async () => {
const onClose = vi.fn();
const onUseDraft = vi.fn();

View File

@@ -40,9 +40,17 @@ vi.mock("../ExperimentalAgentOnboardingModal", () => ({
name: "Interview Draft",
role: "reviewer",
title: "Interview Title",
icon: "🤖",
reportsTo: "agent-manager-1",
instructionsText: "Interview instructions",
soul: "Interview soul",
memory: "Interview memory",
skills: ["skill-1"],
heartbeatProcedurePath: ".fusion/agents/interview/HEARTBEAT.md",
runtimeHint: "openclaw",
thinkingLevel: "low",
maxTurns: 12,
heartbeatIntervalMs: 45000,
})}
>
Apply Interview Draft
@@ -305,15 +313,24 @@ describe("NewAgentDialog", () => {
await waitFor(() => {
expect(onPrefillDraft).toHaveBeenCalledWith(expect.objectContaining({ name: "Interview Draft" }));
expect(screen.getByRole("button", { name: "Model" })).toBeInTheDocument();
expect(screen.getByLabelText("Runtime")).toBeInTheDocument();
});
expect(mockCreateAgent).not.toHaveBeenCalled();
expect(screen.getByLabelText("Runtime")).toBeInTheDocument();
expect((screen.getByLabelText("Runtime") as HTMLSelectElement).value).toBe("openclaw");
await user.click(screen.getByRole("button", { name: "Back" }));
expect((getStepZeroField(/Name/) as HTMLInputElement).value).toBe("Interview Draft");
expect((getStepZeroField(/Title/) as HTMLInputElement).value).toBe("Interview Title");
expect((getStepZeroField(/Icon/) as HTMLInputElement).value).toBe("🤖");
expect((getStepZeroField(/Reports To/) as HTMLSelectElement).value).toBe("agent-manager-1");
expect((getStepZeroField(/Soul/) as HTMLTextAreaElement).value).toBe("Interview soul");
expect((getStepZeroField(/Agent Memory/) as HTMLTextAreaElement).value).toBe("Interview memory");
expect((getStepZeroField(/Heartbeat Procedure Path/) as HTMLInputElement).value).toBe(".fusion/agents/interview/HEARTBEAT.md");
await user.click(screen.getByRole("button", { name: "Next" }));
expect((screen.getByTestId("skill-multiselect-value")).textContent).toContain("skill-1");
await user.click(screen.getByRole("button", { name: "Next" }));
expect(mockCreateAgent).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Create" }));
@@ -321,6 +338,17 @@ describe("NewAgentDialog", () => {
await waitFor(() => {
expect(mockCreateAgent).toHaveBeenCalledOnce();
});
expect(mockCreateAgent).toHaveBeenCalledWith(
expect.objectContaining({
name: "Interview Draft",
role: "reviewer",
reportsTo: "agent-manager-1",
heartbeatProcedurePath: ".fusion/agents/interview/HEARTBEAT.md",
runtimeConfig: expect.objectContaining({ runtimeHint: "openclaw", thinkingLevel: "low", maxTurns: 12 }),
metadata: { skills: ["skill-1"] },
}),
undefined,
);
});
it("closing interview leaves current form state unchanged and does not create agent", async () => {