feat(FN-3556): add review-before-apply flow with explicit draft review gate

This merge adds an agent permission policy model with persistence (FN-3545), an explicit draft review gate for task workflow steps (FN-3556), web builtin tool opt-in for AI agents (FN-3713), an inline fast mode toggle wired into peer execution (FN-3711), and a retry mechanism for overlapping task ID

Fusion-Task-Id: FN-3556
This commit is contained in:
Fusion
2026-05-07 12:09:36 -07:00
committed by gsxdsm
parent 80cf5f7345
commit 0cfc160bd6
4 changed files with 110 additions and 81 deletions

View File

@@ -264,7 +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. - **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 - **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 - **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. - **AI Interview drafts (experimental)**: Interview-generated drafts now stop on a dedicated read-only review summary before any data is applied to the editable New Agent form. The summary mirrors form-aligned sections and surfaces 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`), with an explicit apply action required to continue.
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Running, Error) using CSS variable references for theme consistency - **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 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: - **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

@@ -21,11 +21,42 @@
font-size: calc(var(--space-md) + var(--space-xs) * 0.25); font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
} }
.experimental-agent-onboarding-modal__summary-intro {
margin: var(--space-sm) 0;
color: var(--text-muted);
}
.experimental-agent-onboarding-modal__summary-section p { .experimental-agent-onboarding-modal__summary-section p {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text-muted);
} }
.experimental-agent-onboarding-modal__summary-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin: 0;
}
.experimental-agent-onboarding-modal__summary-list > div {
display: grid;
gap: var(--space-sm);
grid-template-columns: minmax(0, calc(var(--space-xl) * 5)) minmax(0, 1fr);
}
.experimental-agent-onboarding-modal__summary-list dt {
color: var(--text-muted);
}
.experimental-agent-onboarding-modal__summary-list dd {
margin: 0;
color: var(--text);
}
.experimental-agent-onboarding-modal__summary-empty {
color: var(--text-dim);
}
.experimental-agent-onboarding-modal__summary-block { .experimental-agent-onboarding-modal__summary-block {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -39,4 +70,9 @@
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-md); padding: var(--space-md);
} }
.experimental-agent-onboarding-modal__summary-list > div {
grid-template-columns: 1fr;
gap: var(--space-xs);
}
} }

View File

@@ -111,26 +111,12 @@ export function ExperimentalAgentOnboardingModal({
if (!isOpen) return null; if (!isOpen) return null;
const instructionsExcerpt = summary?.instructionsText const renderSummaryValue = (value: string | number | null | undefined) => {
? summary.instructionsText.length > 220 if (value === undefined || value === null || value === "") {
? `${summary.instructionsText.slice(0, 220)}…` return <em className="experimental-agent-onboarding-modal__summary-empty">Not set</em>;
: summary.instructionsText }
: ""; return <span>{value}</span>;
};
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 () => { const start = async () => {
setViewState("loading"); setViewState("loading");
@@ -209,62 +195,58 @@ export function ExperimentalAgentOnboardingModal({
{viewState === "summary" && summary && ( {viewState === "summary" && summary && (
<div className="form-group"> <div className="form-group">
<label>{isEditMode ? "Updated draft ready for review" : "Draft ready for review"}</label> <label>{isEditMode ? "Updated draft ready for review" : "Draft ready for review"}</label>
<p className="experimental-agent-onboarding-modal__summary-intro">
Review this generated draft. Nothing is applied until you confirm.
</p>
<div className="experimental-agent-onboarding-modal__summary card"> <div className="experimental-agent-onboarding-modal__summary card">
<div className="experimental-agent-onboarding-modal__summary-section"> <div className="experimental-agent-onboarding-modal__summary-section">
<h4>Profile</h4> <h4>Identity</h4>
<p><strong>Name:</strong> {summary.name}</p> <dl className="experimental-agent-onboarding-modal__summary-list">
<p><strong>Role:</strong> {summary.role}</p> <div><dt>Name</dt><dd>{renderSummaryValue(summary.name)}</dd></div>
{summary.title && <p><strong>Title:</strong> {summary.title}</p>} <div><dt>Role</dt><dd>{renderSummaryValue(summary.role)}</dd></div>
{summary.icon && <p><strong>Icon:</strong> {summary.icon}</p>} <div><dt>Title</dt><dd>{renderSummaryValue(summary.title)}</dd></div>
{summary.templateId && <p><strong>Template:</strong> {summary.templateId}</p>} <div><dt>Icon</dt><dd>{renderSummaryValue(summary.icon)}</dd></div>
{summary.patternAgentId && <p><strong>Pattern agent:</strong> {summary.patternAgentId}</p>} <div><dt>Reports To</dt><dd>{renderSummaryValue(summary.reportsTo)}</dd></div>
{summary.reportsTo && <p><strong>Reports to:</strong> {summary.reportsTo}</p>} </dl>
{summary.rationale && <p><strong>Why:</strong> {summary.rationale}</p>}
</div> </div>
{summary.soul && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Soul / personality</h4>
<p className="experimental-agent-onboarding-modal__summary-block">{summary.soul}</p>
</div>
)}
<div className="experimental-agent-onboarding-modal__summary-section"> <div className="experimental-agent-onboarding-modal__summary-section">
<h4>Core instructions</h4> <h4>Configuration</h4>
<p className="experimental-agent-onboarding-modal__summary-block">{instructionsExcerpt}</p> <dl className="experimental-agent-onboarding-modal__summary-list">
<div><dt>Inline Instructions</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.instructionsText)}</dd></div>
<div><dt>Soul</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.soul)}</dd></div>
<div><dt>Agent Memory</dt><dd className="experimental-agent-onboarding-modal__summary-block">{renderSummaryValue(summary.memory)}</dd></div>
<div><dt>Skills</dt><dd>{renderSummaryValue(summary.skills?.join(", "))}</dd></div>
<div><dt>Thinking Level</dt><dd>{renderSummaryValue(summary.thinkingLevel)}</dd></div>
<div><dt>Max Turns</dt><dd>{renderSummaryValue(summary.maxTurns)}</dd></div>
<div><dt>Template</dt><dd>{renderSummaryValue(summary.templateId)}</dd></div>
<div><dt>Pattern Agent</dt><dd>{renderSummaryValue(summary.patternAgentId)}</dd></div>
</dl>
</div> </div>
{heartbeatSummary && ( {(summary.heartbeatProcedurePath || summary.heartbeatIntervalMs || summary.heartbeatEnabled !== undefined || summary.modelHint || summary.runtimeHint) && (
<div className="experimental-agent-onboarding-modal__summary-section"> <div className="experimental-agent-onboarding-modal__summary-section">
<h4>Heartbeat summary</h4> <h4>Runtime Hints</h4>
<p>{heartbeatSummary}</p> <dl className="experimental-agent-onboarding-modal__summary-list">
<div><dt>Heartbeat Procedure Path</dt><dd>{renderSummaryValue(summary.heartbeatProcedurePath)}</dd></div>
<div><dt>Heartbeat Interval</dt><dd>{renderSummaryValue(summary.heartbeatIntervalMs ? `${summary.heartbeatIntervalMs}ms` : undefined)}</dd></div>
<div><dt>Heartbeat Enabled</dt><dd>{renderSummaryValue(summary.heartbeatEnabled === undefined ? undefined : summary.heartbeatEnabled ? "yes" : "no")}</dd></div>
<div><dt>Model Hint</dt><dd>{renderSummaryValue(summary.modelHint)}</dd></div>
<div><dt>Runtime Hint</dt><dd>{renderSummaryValue(summary.runtimeHint)}</dd></div>
</dl>
</div> </div>
)} )}
{runtimeSummary && ( {summary.rationale && (
<div className="experimental-agent-onboarding-modal__summary-section"> <div className="experimental-agent-onboarding-modal__summary-section">
<h4>Runtime summary</h4> <h4>Rationale</h4>
<p>{runtimeSummary}</p> <p className="experimental-agent-onboarding-modal__summary-block">{summary.rationale}</p>
</div>
)}
{summary.memory && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Starter memory / playbook</h4>
<p className="experimental-agent-onboarding-modal__summary-block">{summary.memory}</p>
</div>
)}
{summary.skills && summary.skills.length > 0 && (
<div className="experimental-agent-onboarding-modal__summary-section">
<h4>Skills</h4>
<p>{summary.skills.join(", ")}</p>
</div> </div>
)} )}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button> <button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" onClick={() => onUseDraft(summary)}>{isEditMode ? "Apply draft to settings" : "Continue to agent form"}</button> <button className="btn btn-primary" onClick={() => onUseDraft(summary)}>{isEditMode ? "Apply draft to settings form" : "Apply draft to agent form"}</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -50,7 +50,7 @@ describe("ExperimentalAgentOnboardingModal", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("walks onboarding flow and hands draft to create form", async () => { it("renders draft review and only applies after explicit confirmation", async () => {
const onUseDraft = vi.fn(); const onUseDraft = vi.fn();
render( render(
<ExperimentalAgentOnboardingModal <ExperimentalAgentOnboardingModal
@@ -77,25 +77,30 @@ describe("ExperimentalAgentOnboardingModal", () => {
fireEvent.click(screen.getByText("Continue")); fireEvent.click(screen.getByText("Continue"));
await screen.findByText("Draft ready for review"); await screen.findByText("Draft ready for review");
expect(screen.getByText("Template:")).toBeTruthy(); expect(screen.getByText("Identity")).toBeTruthy();
expect(screen.getByText("reviewer-template")).toBeTruthy(); expect(screen.getByText("Configuration")).toBeTruthy();
expect(screen.getByText(/Matched your request/)).toBeTruthy(); expect(screen.getByText("Runtime Hints")).toBeTruthy();
expect(screen.getByText("Soul / personality")).toBeTruthy(); expect(screen.getByText("Rationale")).toBeTruthy();
expect(screen.getByText("Thorough and empathetic reviewer.")).toBeTruthy(); expect(screen.getByText("Title")).toBeTruthy();
expect(screen.getByText("Core instructions")).toBeTruthy(); expect(screen.getByText("Icon")).toBeTruthy();
expect(screen.getByText("Reports To")).toBeTruthy();
expect(screen.getByText("Soul")).toBeTruthy();
expect(screen.getByText("Agent Memory")).toBeTruthy();
expect(screen.getByText("Thinking Level")).toBeTruthy();
expect(screen.getByText("Max Turns")).toBeTruthy();
expect(screen.getByText("Template")).toBeTruthy();
expect(screen.getByText("Pattern Agent")).toBeTruthy();
expect(screen.getByText("Inline Instructions")).toBeTruthy();
expect(screen.getByText(/Review docs for accuracy and clarity\./)).toBeTruthy(); expect(screen.getByText(/Review docs for accuracy and clarity\./)).toBeTruthy();
expect(screen.getByText("Heartbeat summary")).toBeTruthy(); expect(screen.getByText(/Matched your request/)).toBeTruthy();
expect(screen.getByText(/Procedure: \.fusion\/agents\/docs-reviewer\/HEARTBEAT\.md/)).toBeTruthy(); expect(screen.getByText(/\.fusion\/agents\/docs-reviewer\/HEARTBEAT\.md/)).toBeTruthy();
expect(screen.getByText(/Interval: 45000ms/)).toBeTruthy(); expect(screen.getByText(/45000ms/)).toBeTruthy();
expect(screen.getByText(/Enabled: yes/)).toBeTruthy(); expect(screen.getByText(/anthropic\/claude-sonnet-4-5/)).toBeTruthy();
expect(screen.getByText("Runtime summary")).toBeTruthy(); expect(screen.getByText(/openclaw/)).toBeTruthy();
expect(screen.getByText(/Model hint: anthropic\/claude-sonnet-4-5/)).toBeTruthy(); expect(onUseDraft).not.toHaveBeenCalled();
expect(screen.getByText(/Runtime hint: openclaw/)).toBeTruthy(); expect(screen.queryByRole("button", { name: "Continue to agent form" })).toBeNull();
expect(screen.getByText("Starter memory / playbook")).toBeTruthy();
expect(screen.getByText(/Follow docs style guide/)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Apply draft to agent form" }));
expect(screen.getByText("Skills")).toBeTruthy();
expect(screen.getByText("docs, review")).toBeTruthy();
fireEvent.click(screen.getByText("Continue to agent form"));
await waitFor(() => { await waitFor(() => {
expect(onUseDraft).toHaveBeenCalledWith(expect.objectContaining({ name: "Docs Reviewer" })); expect(onUseDraft).toHaveBeenCalledWith(expect.objectContaining({ name: "Docs Reviewer" }));
@@ -137,7 +142,7 @@ describe("ExperimentalAgentOnboardingModal", () => {
fireEvent.click(screen.getByText("Continue")); fireEvent.click(screen.getByText("Continue"));
await screen.findByText("Updated draft ready for review"); await screen.findByText("Updated draft ready for review");
expect(screen.getByRole("button", { name: "Apply draft to settings" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Apply draft to settings form" })).toBeTruthy();
}); });
it("renders stream errors and still closes cleanly", async () => { it("renders stream errors and still closes cleanly", async () => {
@@ -161,11 +166,12 @@ describe("ExperimentalAgentOnboardingModal", () => {
it("cancels server session on close", async () => { it("cancels server session on close", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onUseDraft = vi.fn();
render( render(
<ExperimentalAgentOnboardingModal <ExperimentalAgentOnboardingModal
isOpen={true} isOpen={true}
onClose={onClose} onClose={onClose}
onUseDraft={vi.fn()} onUseDraft={onUseDraft}
existingAgents={[]} existingAgents={[]}
/>, />,
); );
@@ -174,11 +180,16 @@ describe("ExperimentalAgentOnboardingModal", () => {
fireEvent.click(screen.getByText("Start onboarding")); fireEvent.click(screen.getByText("Start onboarding"));
await screen.findByText("What should this agent primarily help with?"); 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("Draft ready for review");
fireEvent.click(screen.getByLabelText("Close")); fireEvent.click(screen.getByLabelText("Close"));
await waitFor(() => { await waitFor(() => {
expect(mockCancel).toHaveBeenCalledWith("onb-1", undefined); expect(mockCancel).toHaveBeenCalledWith("onb-1", undefined);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
expect(onUseDraft).not.toHaveBeenCalled();
}); });
}); });