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:
7
.changeset/fn-6018-agent-error-stop-delete.md
Normal file
7
.changeset/fn-6018-agent-error-stop-delete.md
Normal 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.
|
||||
@@ -153,6 +153,22 @@ describe("fn pi extension tool copy guardrails", () => {
|
||||
expect(guidelines).toMatch(/soft.?delete/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
|
||||
|
||||
@@ -3592,9 +3592,9 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
"Transitions the agent from running/active to paused state.",
|
||||
promptSnippet: "Stop (pause) a running Fusion agent",
|
||||
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",
|
||||
"Agents in 'idle', 'error', or already-paused state cannot be stopped",
|
||||
"Agents in 'idle' or already-paused state cannot be stopped",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),
|
||||
|
||||
@@ -1669,6 +1669,22 @@ describe("AgentStore", () => {
|
||||
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 () => {
|
||||
const agent = await createReadyAgent(store, "ActiveToTerminated");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
|
||||
@@ -5501,7 +5501,7 @@ export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
|
||||
active: ["idle", "running", "paused", "error"],
|
||||
running: ["idle", "active", "paused", "error"],
|
||||
paused: ["idle", "active"],
|
||||
error: ["idle", "active"],
|
||||
error: ["idle", "active", "paused"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -797,6 +797,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Square size={14} />
|
||||
<span className="agent-detail-control-label">{t("agents.stop", "Stop")}</span>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
</button>
|
||||
{(agent.state === "idle" || agent.state === "paused") && (
|
||||
{(agent.state === "idle" || agent.state === "paused" || agent.state === "error") && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
render(
|
||||
@@ -981,7 +981,11 @@ it("shows Retry and Stop buttons for error agent", async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Retry")).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 () => {
|
||||
@@ -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 () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -2102,12 +2102,12 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
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} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
@@ -2175,6 +2192,29 @@ describe("AgentsView", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user