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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user