FN-6018: allow stopping and deleting failed agents

Let failed agents be stopped and deleted consistently across the dashboard and agent lifecycle.

- allow agents in the error state to transition to paused and cover the new lifecycle path in core tests
- expose delete controls for failed agents in the agents list and mobile detail view with dashboard regression coverage
- update CLI stop-tool guidance and add a patch changeset for the published package

Files changed:
 .changeset/fn-6018-agent-error-stop-delete.md      |  7 ++++
 packages/cli/src/__tests__/extension.test.ts       | 16 ++++++++
 packages/cli/src/extension.ts                      |  4 +-
 packages/core/src/__tests__/agent-store.test.ts    | 16 ++++++++
 packages/core/src/types.ts                         |  2 +-
 .../dashboard/app/components/AgentDetailView.tsx   |  4 ++
 packages/dashboard/app/components/AgentsView.tsx   |  2 +-
 .../__tests__/AgentDetailView.core.test.tsx        | 24 +++++++++++-
 .../app/components/__tests__/AgentsView.test.tsx   | 44 +++++++++++++++++++++-
 9 files changed, 112 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6018

Fusion-Task-Lineage: 40159b9b-4d14-4f1e-9bd9-d018e54db7ee
This commit is contained in:
gsxdsm
2026-06-08 09:33:29 -07:00
parent 84330f7dc9
commit 60eb2ec995
9 changed files with 112 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Allow failed agents to be stopped and deleted consistently across the dashboard and CLI guidance.
Agents in the error state can now transition to paused, the dashboard exposes delete actions for failed agents in list/detail views, and regression coverage protects the updated behavior.

View File

@@ -153,6 +153,22 @@ describe("fn pi extension tool copy guardrails", () => {
expect(guidelines).toMatch(/soft.?delete/i); expect(guidelines).toMatch(/soft.?delete/i);
expect(guidelines).not.toMatch(/permanent|cannot be recovered|cannot be undone|deleted immediately|irrecoverable/i); expect(guidelines).not.toMatch(/permanent|cannot be recovered|cannot be undone|deleted immediately|irrecoverable/i);
}); });
it("describes fn_agent_stop as allowing error-state agents to be paused (FN-6018)", () => {
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_agent_stop") as
| { description?: string; promptGuidelines?: string[] }
| undefined;
expect(tool).toBeDefined();
const guidelines = (tool?.promptGuidelines ?? []).join(" ");
expect(guidelines).toMatch(/running, active, or in error/i);
expect(guidelines).toMatch(/idle.*already-paused/i);
expect(guidelines).not.toMatch(/idle, 'error', or already-paused/i);
});
}); });
// Audited in FN-3189: this exhaustive suite is expensive (~62s) and stale // Audited in FN-3189: this exhaustive suite is expensive (~62s) and stale

View File

@@ -3592,9 +3592,9 @@ export default function kbExtension(pi: ExtensionAPI) {
"Transitions the agent from running/active to paused state.", "Transitions the agent from running/active to paused state.",
promptSnippet: "Stop (pause) a running Fusion agent", promptSnippet: "Stop (pause) a running Fusion agent",
promptGuidelines: [ promptGuidelines: [
"Use to pause an agent that is currently running or active", "Use to pause an agent that is currently running, active, or in error",
"Stopped agents can be resumed with fn_agent_start", "Stopped agents can be resumed with fn_agent_start",
"Agents in 'idle', 'error', or already-paused state cannot be stopped", "Agents in 'idle' or already-paused state cannot be stopped",
], ],
parameters: Type.Object({ parameters: Type.Object({
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }), id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),

View File

@@ -1669,6 +1669,22 @@ describe("AgentStore", () => {
expect(updated.state).toBe("active"); expect(updated.state).toBe("active");
}); });
it("error → paused transition succeeds", async () => {
const agent = await createReadyAgent(store, "ErrorToPaused");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "error");
const updated = await store.updateAgentState(agent.id, "paused");
expect(updated.state).toBe("paused");
});
it("error → idle transition succeeds", async () => {
const agent = await createReadyAgent(store, "ErrorToIdle");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "error");
const updated = await store.updateAgentState(agent.id, "idle");
expect(updated.state).toBe("idle");
});
it("rejects active → terminated transition", async () => { it("rejects active → terminated transition", async () => {
const agent = await createReadyAgent(store, "ActiveToTerminated"); const agent = await createReadyAgent(store, "ActiveToTerminated");
await store.updateAgentState(agent.id, "active"); await store.updateAgentState(agent.id, "active");

View File

@@ -5501,7 +5501,7 @@ export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
active: ["idle", "running", "paused", "error"], active: ["idle", "running", "paused", "error"],
running: ["idle", "active", "paused", "error"], running: ["idle", "active", "paused", "error"],
paused: ["idle", "active"], paused: ["idle", "active"],
error: ["idle", "active"], error: ["idle", "active", "paused"],
}; };
/** /**

View File

@@ -797,6 +797,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<Square size={14} /> <Square size={14} />
<span className="agent-detail-control-label">{t("agents.stop", "Stop")}</span> <span className="agent-detail-control-label">{t("agents.stop", "Stop")}</span>
</button> </button>
<button className="btn btn--danger btn--compact agent-detail-mobile-icon-control" onClick={handleDelete} aria-label={t("agents.delete", "Delete")}>
<Trash2 size={14} />
<span className="agent-detail-control-label">{t("agents.delete", "Delete")}</span>
</button>
</> </>
)} )}
</div> </div>

View File

@@ -1937,7 +1937,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
> >
<Info size={14} /> <span className="agent-card-action-label">{t("agents.details", "Details")}</span> <Info size={14} /> <span className="agent-card-action-label">{t("agents.details", "Details")}</span>
</button> </button>
{(agent.state === "idle" || agent.state === "paused") && ( {(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && (
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-danger"
onClick={() => void handleDelete(agent.id, agent.name)} onClick={() => void handleDelete(agent.id, agent.name)}

View File

@@ -967,7 +967,7 @@ it("transitions running agent to paused when Stop is clicked", async () => {
}); });
}); });
it("shows Retry and Stop buttons for error agent", async () => { it("shows Retry, Stop, and Delete buttons for error agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" })); mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
render( render(
@@ -981,7 +981,11 @@ it("shows Retry and Stop buttons for error agent", async () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Retry")).toBeInTheDocument(); expect(screen.getByText("Retry")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument(); expect(screen.getByText("Stop")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
}); });
const deleteButton = screen.getByRole("button", { name: "Delete" });
expect(deleteButton.className).toContain("agent-detail-mobile-icon-control");
}); });
it("transitions error agent to paused when Stop is clicked", async () => { it("transitions error agent to paused when Stop is clicked", async () => {
@@ -1002,6 +1006,24 @@ it("transitions error agent to paused when Stop is clicked", async () => {
}); });
}); });
it("deletes error agent when Delete is clicked", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await userEvent.click(await screen.findByText("Delete"));
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-001", undefined);
});
});
it("groups lifecycle and utility controls under a shared header action cluster", async () => { it("groups lifecycle and utility controls under a shared header action cluster", async () => {
render( render(
<AgentDetailView <AgentDetailView

View File

@@ -2102,12 +2102,12 @@ describe("AgentsView", () => {
}); });
describe("delete agent", () => { describe("delete agent", () => {
it("shows Delete button for idle and paused agents in default view", async () => { it("shows Delete button for idle, paused, and error agents in default view", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => { await waitFor(() => {
const deleteButtons = screen.getAllByTitle("Delete"); const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(2); expect(deleteButtons.length).toBeGreaterThanOrEqual(3);
}); });
}); });
@@ -2133,6 +2133,23 @@ describe("AgentsView", () => {
}); });
}); });
it("shows Delete button for error agents in board view", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
const boardCards = Array.from(document.querySelectorAll(".agent-board-card"));
const errorCard = boardCards.find((card) => card.textContent?.includes("agent-004")) ?? null;
expect((errorCard as Element | null)?.querySelector('[title="Delete"]')).toBeTruthy();
});
});
it("deletes idle agent after confirmation (from default view)", async () => { it("deletes idle agent after confirmation (from default view)", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
@@ -2175,6 +2192,29 @@ describe("AgentsView", () => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-003", undefined); expect(mockDeleteAgent).toHaveBeenCalledWith("agent-003", undefined);
}); });
}); });
it("deletes error agent after confirmation (from board view)", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("Agents")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("Board view"));
await waitFor(() => {
const errorCard = Array.from(document.querySelectorAll(".agent-board-card")).find(
(card) => card.textContent?.includes("agent-004"),
) ?? null;
const errorDeleteBtn = (errorCard as Element | null)?.querySelector('[title="Delete"]') as HTMLElement;
expect(errorDeleteBtn).toBeTruthy();
fireEvent.click(errorDeleteBtn);
});
await waitFor(() => {
expect(mockDeleteAgent).toHaveBeenCalledWith("agent-004", undefined);
});
});
}); });
describe("refresh functionality", () => { describe("refresh functionality", () => {