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:
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user