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
This commit is contained in:
Fusion
2026-05-07 11:21:06 -07:00
committed by gsxdsm
parent edbc8b2535
commit af0fc5fc95
9 changed files with 209 additions and 18 deletions

View File

@@ -412,7 +412,7 @@ When **Settings → Experimental Features → Planning-style Agent Onboarding**
- Step 0 of the **New Agent** dialog includes an **AI Interview** entry point for create mode.
- **Agent detail → Settings** includes an **AI Interview** action for edit mode on existing agents.
- The interview flow asks clarifying questions using repo-aware context (existing agents + preset/template options for create mode, plus current agent configuration for edit mode).
- It generates a **draft** configuration summary for review.
- It generates a **draft** configuration summary for review, including identity fields, `soul`, starter `instructionsText`, starter `memory`, heartbeat guidance (`heartbeatProcedurePath`, `heartbeatIntervalMs`, `heartbeatEnabled`), and draft-only runtime/model suggestions (`runtimeHint`, `modelHint`).
- In create mode, **Continue to agent form** prefills `NewAgentDialog`; in edit mode, **Apply draft to settings** updates local editable fields in the settings UI.
- The interview flow does **not** auto-create or auto-save agents directly.

View File

@@ -264,6 +264,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
- **All States Behavior**: The default filter shows all durable agents, including paused and error agents, so stopped/problem agents stay visible without a dedicated terminated bucket. This behavior applies to both the main AgentsView and the AgentListModal.
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and paused agents
- **AI Interview drafts (experimental)**: Interview-generated drafts now carry richer flat fields end-to-end before final apply: identity (`name`, `role`, `title`, `icon`, `reportsTo`), starter operating guidance (`instructionsText`) and starter memory (`memory`), personality (`soul`), heartbeat guidance (`heartbeatProcedurePath`, `heartbeatIntervalMs`, `heartbeatEnabled`), and draft-only runtime/model suggestions (`runtimeHint`, `modelHint`). The lightweight preview highlights these hints so users can review them before applying to the New Agent form.
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Running, Error) using CSS variable references for theme consistency
- **Agent Error Details**: Agent collection views now show a compact inline error indicator (instead of raw stack traces) that opens a shared error-details modal with full text, copy action, and a prefilled "Report on GitHub" shortcut
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. In list view, each agent card also provides an explicit **View Details** action button in the card actions row for clearer discoverability, while the existing clickable identity/header area remains supported. The modal features a compact header with clear visual hierarchy:

View File

@@ -2684,7 +2684,13 @@ export interface AgentOnboardingSummary {
patternAgentId?: string;
rationale?: string;
model?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.model selection. */
modelHint?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.runtimeHint plugin runtime selection. */
runtimeHint?: string;
heartbeatProcedurePath?: string;
heartbeatIntervalMs?: number;
heartbeatEnabled?: boolean;
}
export type OnboardingMode = "create" | "edit";

View File

@@ -111,6 +111,27 @@ export function ExperimentalAgentOnboardingModal({
if (!isOpen) return null;
const instructionsExcerpt = summary?.instructionsText
? summary.instructionsText.length > 220
? `${summary.instructionsText.slice(0, 220)}…`
: summary.instructionsText
: "";
const heartbeatSummary = summary
? [
summary.heartbeatProcedurePath ? `Procedure: ${summary.heartbeatProcedurePath}` : null,
summary.heartbeatIntervalMs ? `Interval: ${summary.heartbeatIntervalMs}ms` : null,
summary.heartbeatEnabled !== undefined ? `Enabled: ${summary.heartbeatEnabled ? "yes" : "no"}` : null,
].filter((value): value is string => Boolean(value)).join(" • ")
: "";
const runtimeSummary = summary
? [
summary.modelHint ? `Model hint: ${summary.modelHint}` : null,
summary.runtimeHint ? `Runtime hint: ${summary.runtimeHint}` : null,
].filter((value): value is string => Boolean(value)).join(" • ")
: "";
const start = async () => {
setViewState("loading");
setError(null);
@@ -210,14 +231,22 @@ export function ExperimentalAgentOnboardingModal({
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Core instructions</h4>
<p className="experimental-agent-onboarding-modal__summary-block">{summary.instructionsText}</p>
<p className="experimental-agent-onboarding-modal__summary-block">{instructionsExcerpt}</p>
</div>
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Runtime hints</h4>
<p><strong>Thinking level:</strong> {summary.thinkingLevel}</p>
<p><strong>Max turns:</strong> {summary.maxTurns}</p>
</div>
{heartbeatSummary && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Heartbeat summary</h4>
<p>{heartbeatSummary}</p>
</div>
)}
{runtimeSummary && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Runtime summary</h4>
<p>{runtimeSummary}</p>
</div>
)}
{summary.memory && (
<div className="experimental-agent-onboarding-modal__summary-section">

View File

@@ -243,6 +243,7 @@ export function NewAgentDialog({
setRole((VALID_CAPABILITIES.has(draft.role) ? draft.role : "custom") as AgentCapability);
setReportsTo(draft.reportsTo ?? "");
setInstructionsText(draft.instructionsText ?? "");
setHeartbeatProcedurePath(draft.heartbeatProcedurePath ?? "");
setSoul(draft.soul ?? "");
setMemory(draft.memory ?? "");
setSelectedSkills(draft.skills ?? []);

View File

@@ -22,7 +22,7 @@ vi.mock("../../api", () => ({
streamHandlers?.onSummary?.({
name: "Docs Reviewer",
role: "reviewer",
instructionsText: "Review docs for accuracy and clarity.",
instructionsText: "Review docs for accuracy and clarity. Focus on sequencing, examples, and edge cases.",
thinkingLevel: "medium",
maxTurns: 20,
soul: "Thorough and empathetic reviewer.",
@@ -30,6 +30,11 @@ vi.mock("../../api", () => ({
skills: ["docs", "review"],
templateId: "reviewer-template",
rationale: "Matched your request to the reviewer preset",
heartbeatProcedurePath: ".fusion/agents/docs-reviewer/HEARTBEAT.md",
heartbeatIntervalMs: 45000,
heartbeatEnabled: true,
modelHint: "anthropic/claude-sonnet-4-5",
runtimeHint: "openclaw",
}),
0,
);
@@ -78,10 +83,14 @@ describe("ExperimentalAgentOnboardingModal", () => {
expect(screen.getByText("Soul / personality")).toBeTruthy();
expect(screen.getByText("Thorough and empathetic reviewer.")).toBeTruthy();
expect(screen.getByText("Core instructions")).toBeTruthy();
expect(screen.getByText("Review docs for accuracy and clarity.")).toBeTruthy();
expect(screen.getByText("Runtime hints")).toBeTruthy();
expect(screen.getByText("Thinking level:")).toBeTruthy();
expect(screen.getByText("Max turns:")).toBeTruthy();
expect(screen.getByText(/Review docs for accuracy and clarity\./)).toBeTruthy();
expect(screen.getByText("Heartbeat summary")).toBeTruthy();
expect(screen.getByText(/Procedure: \.fusion\/agents\/docs-reviewer\/HEARTBEAT\.md/)).toBeTruthy();
expect(screen.getByText(/Interval: 45000ms/)).toBeTruthy();
expect(screen.getByText(/Enabled: yes/)).toBeTruthy();
expect(screen.getByText("Runtime summary")).toBeTruthy();
expect(screen.getByText(/Model hint: anthropic\/claude-sonnet-4-5/)).toBeTruthy();
expect(screen.getByText(/Runtime hint: openclaw/)).toBeTruthy();
expect(screen.getByText("Starter memory / playbook")).toBeTruthy();
expect(screen.getByText(/Follow docs style guide/)).toBeTruthy();
expect(screen.getByText("Skills")).toBeTruthy();

View File

@@ -1853,7 +1853,7 @@ describe("NewAgentDialog", () => {
expect(createCall.metadata).toBeUndefined();
});
it("prefills fields from onboarding draft", async () => {
it("prefills rich onboarding draft fields", async () => {
render(
<NewAgentDialog
isOpen={true}
@@ -1867,7 +1867,15 @@ describe("NewAgentDialog", () => {
maxTurns: 25,
title: "Draft title",
icon: "🧪",
reportsTo: "agent-manager-1",
soul: "Patient",
memory: "Remember docs style",
skills: ["docs", "review"],
heartbeatProcedurePath: ".fusion/agents/draft-agent/HEARTBEAT.md",
modelHint: "anthropic/claude-sonnet-4-5",
runtimeHint: "openclaw",
heartbeatIntervalMs: 60000,
heartbeatEnabled: true,
}}
/>,
);
@@ -1879,7 +1887,14 @@ describe("NewAgentDialog", () => {
expect((getStepZeroField(/Name/) as HTMLInputElement).value).toBe("Draft Agent");
expect((getStepZeroField(/Title/) as HTMLInputElement).value).toBe("Draft 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("Patient");
expect((getStepZeroField(/Agent Memory/) as HTMLTextAreaElement).value).toBe("Remember docs style");
expect((getStepZeroField(/Heartbeat Procedure Path/) as HTMLInputElement).value).toBe(".fusion/agents/draft-agent/HEARTBEAT.md");
expect((getStepZeroField(/^Inline Instructions/) as HTMLTextAreaElement).value).toContain("Review with care");
fireEvent.click(screen.getByText("Next"));
expect(screen.getByTestId("skill-multiselect-value")).toHaveTextContent('["docs","review"]');
});
});
});

View File

@@ -74,7 +74,7 @@ describe("agent-onboarding", () => {
}
});
it("parses complete summary responses", () => {
it("parses complete summary responses with rich optional draft fields", () => {
const parsed = parseAgentOnboardingResponse(
JSON.stringify({
type: "complete",
@@ -84,6 +84,13 @@ describe("agent-onboarding", () => {
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",
},
}),
);
@@ -92,6 +99,34 @@ describe("agent-onboarding", () => {
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();
}
});
@@ -112,6 +147,58 @@ describe("agent-onboarding", () => {
).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",

View File

@@ -21,7 +21,13 @@ export interface AgentOnboardingSummary {
patternAgentId?: string;
rationale?: string;
model?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.model selection. */
modelHint?: string;
/** Draft-only AI suggestion for eventual runtimeConfig.runtimeHint plugin runtime selection. */
runtimeHint?: string;
heartbeatProcedurePath?: string;
heartbeatIntervalMs?: number;
heartbeatEnabled?: boolean;
}
export type OnboardingMode = "create" | "edit";
@@ -68,13 +74,15 @@ 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":"..."}}
{"type":"complete","data":{"name":"...","role":"executor","instructionsText":"...","thinkingLevel":"medium","maxTurns":25,"title":"...","icon":"🤖","reportsTo":"...","soul":"...","memory":"...","skills":["..."],"templateId":"...","patternAgentId":"...","rationale":"...","heartbeatProcedurePath":"...","heartbeatIntervalMs":30000,"heartbeatEnabled":true,"modelHint":"...","runtimeHint":"..."}}
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.`;
- Use instructionsText for starter operating guidance/playbook content; do not create a separate playbook field
- modelHint and runtimeHint are optional draft suggestions only (not final runtime selection)
- heartbeatProcedurePath, heartbeatIntervalMs, and heartbeatEnabled are optional draft hints only.`;
type OnboardingAgent = Awaited<ReturnType<typeof engineCreateFnAgent>>;
@@ -174,14 +182,49 @@ export function parseAgentOnboardingResponse(text: string): { type: "question";
}
if (typed.type === "complete") {
const data = (typed.data ?? {}) as { name?: unknown; instructionsText?: unknown; maxTurns?: unknown };
const data = (typed.data ?? {}) as AgentOnboardingSummary & {
name?: unknown;
instructionsText?: unknown;
maxTurns?: unknown;
heartbeatProcedurePath?: unknown;
heartbeatIntervalMs?: unknown;
heartbeatEnabled?: unknown;
modelHint?: unknown;
runtimeHint?: unknown;
};
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");
const maxTurns = data.maxTurns;
if (typeof maxTurns !== "number" || !Number.isInteger(maxTurns) || maxTurns <= 0) {
throw new Error("Invalid summary.maxTurns");
}
return { type: "complete", data: typed.data as AgentOnboardingSummary };
if (data.heartbeatProcedurePath !== undefined) {
if (typeof data.heartbeatProcedurePath !== "string" || !data.heartbeatProcedurePath.trim()) {
throw new Error("Invalid summary.heartbeatProcedurePath");
}
data.heartbeatProcedurePath = data.heartbeatProcedurePath.trim();
}
if (data.heartbeatIntervalMs !== undefined) {
if (typeof data.heartbeatIntervalMs !== "number" || !Number.isInteger(data.heartbeatIntervalMs) || data.heartbeatIntervalMs <= 0) {
throw new Error("Invalid summary.heartbeatIntervalMs");
}
}
if (data.heartbeatEnabled !== undefined && typeof data.heartbeatEnabled !== "boolean") {
throw new Error("Invalid summary.heartbeatEnabled");
}
if (data.modelHint !== undefined && typeof data.modelHint !== "string") {
throw new Error("Invalid summary.modelHint");
}
if (data.runtimeHint !== undefined && typeof data.runtimeHint !== "string") {
throw new Error("Invalid summary.runtimeHint");
}
return { type: "complete", data: data as AgentOnboardingSummary };
}
return { type: "question", data: typed.data as PlanningQuestion };