diff --git a/docs/api/agents.md b/docs/api/agents.md index 70e779a9..8de6f7dd 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -93,6 +93,15 @@ POST /api/agents/{agentId}/resume Resumes heartbeats for a paused agent. +## Clear Agent Error + +``` +POST /api/agents/{agentId}/clear-error +``` + +Moves an agent from `error` back to `idle` without deleting run history or runtime diagnostics. +Only agents currently in `error` can be cleared. + ## Terminate Agent ``` diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3a8e7d7c..d7e9889f 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1142,6 +1142,7 @@ export const PLUGIN_EVENT_TYPES = [ "agent.created", "agent.updated", "agent.status_changed", + "agent.error_cleared", "agent.run.started", "agent.run.finished", "agent.run.failed", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7744af39..6468e6e4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -403,6 +403,7 @@ export type { AgentAccessState, AgentChainOfCommandEntry, AgentDetail, + ClearAgentErrorResponse, AgentPermissions, AgentInstructionsBundleMode, AgentInstructionsFileSummary, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 806ebfc9..7027f00f 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -109,6 +109,8 @@ export interface AgentDetail extends Agent { access: AgentAccessState; } +export type ClearAgentErrorResponse = Agent; + export interface AgentKeyCreated { id: string; name: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b1ea7661..8bc246b8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -128,6 +128,7 @@ export type { AgentAccessState, AgentChainOfCommandEntry, AgentDetail, + ClearAgentErrorResponse, AgentModelProfileConfig, AgentPermissions, AgentRuntimeConfig, diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 1df94d90..765f0c50 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -50,6 +50,7 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), pause: vi.fn(), resume: vi.fn(), + clearError: vi.fn(), terminate: vi.fn(), remove: vi.fn(), listKeys: vi.fn(), @@ -276,6 +277,7 @@ function resetMockDefaults() { mockAgentService.getById.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.pause.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.resume.mockImplementation(async () => ({ ...baseAgent })); + mockAgentService.clearError.mockImplementation(async () => ({ ...baseAgent, status: "idle" })); mockAgentService.terminate.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.remove.mockImplementation(async () => ({ ...baseAgent })); mockAgentService.listKeys.mockImplementation(async () => []); @@ -334,6 +336,12 @@ describe.sequential("agent cross-tenant route authorization", () => { requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/pause`).send({})), untouched: [mockAgentService.pause, mockHeartbeatService.cancelActiveForAgent], }, + { + label: "clear error", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).post(`/api/agents/${agentId}/clear-error`).send({})), + untouched: [mockAgentService.clearError], + }, { label: "list keys", request: (app: express.Express) => @@ -386,4 +394,117 @@ describe.sequential("agent cross-tenant route authorization", () => { expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); }); + + it("requires board access before clearing an agent error", async () => { + const app = await createApp({ + type: "agent", + agentId, + companyId, + runId: "run-1", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/clear-error`).send({}), + ); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Board access required"); + expect(mockAgentService.clearError).not.toHaveBeenCalled(); + }); + + it("clears error agents and records a distinct audit action", async () => { + const errorAgent = { + ...baseAgent, + status: "error", + pauseReason: "system", + pausedAt: new Date("2026-04-11T00:02:00.000Z"), + }; + mockAgentService.getById.mockImplementation(async () => ({ ...errorAgent })); + mockAgentService.clearError.mockImplementation(async () => ({ + ...errorAgent, + status: "idle", + pauseReason: null, + pausedAt: null, + updatedAt: new Date("2026-04-11T00:03:00.000Z"), + })); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/clear-error`).send({}), + ); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: agentId, + status: "idle", + pauseReason: null, + pausedAt: null, + }); + expect(mockAgentService.clearError).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "agent.error_cleared", + entityType: "agent", + entityId: agentId, + })); + }); + + it("returns 409 and does not mutate when the agent org chain is invalid", async () => { + mockAgentService.getById.mockImplementation(async () => ({ + ...baseAgent, + status: "error", + orgChainHealth: { + status: "invalid_org_chain", + reason: "missing_manager", + repairGuidance: "Repair the reporting chain first.", + }, + })); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/clear-error`).send({}), + ); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("Repair the reporting chain first"); + expect(mockAgentService.clearError).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("returns a clear 409 for non-error agents", async () => { + const { conflict } = await import("../errors.js"); + mockAgentService.getById.mockImplementation(async () => ({ ...baseAgent, status: "idle" })); + mockAgentService.clearError.mockImplementation(async () => { + throw conflict("Only agents in error status can have their error cleared"); + }); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/clear-error`).send({}), + ); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Only agents in error status can have their error cleared"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agents-service-clear-error.test.ts b/server/src/__tests__/agents-service-clear-error.test.ts new file mode 100644 index 00000000..1b264160 --- /dev/null +++ b/server/src/__tests__/agents-service-clear-error.test.ts @@ -0,0 +1,236 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + agents, + agentRuntimeState, + companies, + createDb, + heartbeatRunEvents, + heartbeatRuns, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { agentService } from "../services/agents.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres agent service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("agent service clearError", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-clear-error-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(heartbeatRunEvents); + await db.delete(agentRuntimeState); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("moves an error agent to idle without deleting run history or runtime diagnostics", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "error", + pauseReason: "system", + pausedAt: new Date("2026-06-07T00:00:00.000Z"), + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "on_demand", + status: "failed", + error: "Adapter exited with code 1", + stdoutExcerpt: "stdout stays inspectable", + stderrExcerpt: "stderr stays inspectable", + logStore: "local_disk", + logRef: "runs/failed.log", + resultJson: { sessionId: "codex-session-1" }, + finishedAt: new Date("2026-06-07T00:01:00.000Z"), + }); + + await db.insert(heartbeatRunEvents).values({ + companyId, + runId, + agentId, + seq: 1, + eventType: "transcript", + stream: "stderr", + message: "transcript stays inspectable", + payload: { itemType: "error" }, + }); + + await db.insert(agentRuntimeState).values({ + agentId, + companyId, + adapterType: "codex_local", + sessionId: "codex-session-1", + stateJson: { taskKey: "issue:test" }, + lastRunId: runId, + lastRunStatus: "failed", + lastError: "Adapter exited with code 1", + }); + + const cleared = await agentService(db).clearError(agentId); + + expect(cleared).toMatchObject({ + id: agentId, + status: "idle", + pauseReason: null, + pausedAt: null, + }); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run).toMatchObject({ + id: runId, + status: "failed", + error: "Adapter exited with code 1", + stdoutExcerpt: "stdout stays inspectable", + stderrExcerpt: "stderr stays inspectable", + logStore: "local_disk", + logRef: "runs/failed.log", + }); + + const transcriptEvents = await db + .select() + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + expect(transcriptEvents).toHaveLength(1); + expect(transcriptEvents[0]).toMatchObject({ + runId, + eventType: "transcript", + stream: "stderr", + message: "transcript stays inspectable", + payload: { itemType: "error" }, + }); + + const [runtimeState] = await db + .select() + .from(agentRuntimeState) + .where(eq(agentRuntimeState.agentId, agentId)); + expect(runtimeState).toMatchObject({ + agentId, + sessionId: "codex-session-1", + stateJson: { taskKey: "issue:test" }, + lastRunId: runId, + lastRunStatus: "failed", + lastError: "Adapter exited with code 1", + }); + }); + + it("rejects non-error agents with a 409 conflict", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await expect(agentService(db).clearError(agentId)).rejects.toMatchObject({ + status: 409, + message: "Only agents in error status can have their error cleared", + }); + }); + + it("keeps resume-style terminal and pending-approval protections", async () => { + const companyId = randomUUID(); + const terminatedAgentId = randomUUID(); + const pendingAgentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: terminatedAgentId, + companyId, + name: "Terminated", + role: "engineer", + status: "terminated", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: pendingAgentId, + companyId, + name: "Pending", + role: "engineer", + status: "pending_approval", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await expect(agentService(db).clearError(terminatedAgentId)).rejects.toMatchObject({ + status: 409, + message: "Cannot clear error on terminated agent", + }); + await expect(agentService(db).clearError(pendingAgentId)).rejects.toMatchObject({ + status: 409, + message: "Pending approval agents cannot have errors cleared", + }); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a44aca54..fb706f8e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2879,6 +2879,38 @@ export function agentRoutes( res.json(agent); }); + router.post("/agents/:id/clear-error", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await getAccessibleAgent(req, res, id); + if (!existing) { + return; + } + if (existing.orgChainHealth?.status === "invalid_org_chain") { + res.status(409).json({ + error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before clearing its error", + }); + return; + } + + const agent = await svc.clearError(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.error_cleared", + entityType: "agent", + entityId: agent.id, + }); + + res.json(agent); + }); + router.post("/agents/:id/approve", async (req, res) => { assertBoard(req); const id = req.params.id as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 7a0b8fb6..4e599695 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -418,6 +418,10 @@ const responses = { description: "Not found", content: { "application/json": { schema: ErrorSchema } }, }, + conflict: { + description: "Conflict", + content: { "application/json": { schema: ErrorSchema } }, + }, unprocessable: { description: "Unprocessable entity", content: { "application/json": { schema: ErrorSchema } }, @@ -1247,6 +1251,15 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/clear-error", + tags: ["agents"], + summary: "Clear an agent error", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + registry.registerPath({ method: "post", path: "/api/agents/{id}/terminate", diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index dd67518d..f8d37aef 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -526,6 +526,35 @@ export function agentService(db: Db) { return updated ? getById(updated.id) : null; }, + clearError: async (id: string) => { + const existing = await getById(id); + if (!existing) return null; + if (existing.status === "terminated") throw conflict("Cannot clear error on terminated agent"); + if (existing.status === "pending_approval") { + throw conflict("Pending approval agents cannot have errors cleared"); + } + if (existing.status !== "error") { + throw conflict("Only agents in error status can have their error cleared"); + } + + const updated = await db + .update(agents) + .set({ + status: "idle", + pauseReason: null, + pausedAt: null, + updatedAt: new Date(), + }) + .where(and(eq(agents.id, id), eq(agents.status, "error"))) + .returning() + .then((rows) => rows[0] ?? null); + + if (!updated) { + throw conflict("Only agents in error status can have their error cleared"); + } + return getById(updated.id); + }, + terminate: async (id: string) => { const existing = await getById(id); if (!existing) return null; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index e9390b64..3dc1fae7 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -13,6 +13,7 @@ import type { HeartbeatRun, Approval, AgentConfigRevision, + ClearAgentErrorResponse, } from "@paperclipai/shared"; import type { AdapterModelProfileDefinition, @@ -165,6 +166,8 @@ export const agentsApi = { ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), + clearError: (id: string, companyId?: string) => + api.post(agentPath(id, companyId, "/clear-error"), {}), approve: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/approve"), {}), terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx new file mode 100644 index 00000000..14fc4f87 --- /dev/null +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Agent } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentActionButtons } from "./AgentActionButtons"; + +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockOpenNewIssue = vi.hoisted(() => vi.fn()); +const mockPushToast = vi.hoisted(() => vi.fn()); +const mockAgentsApi = vi.hoisted(() => ({ + invoke: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + clearError: vi.fn(), + approve: vi.fn(), + terminate: vi.fn(), + resetSession: vi.fn(), + instructionsBundle: vi.fn(), + instructionsFile: vi.fn(), + create: vi.fn(), + hire: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( + {children} + ), + useNavigate: () => mockNavigate, +})); + +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewIssue: mockOpenNewIssue }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: mockPushToast }), +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "Alpha Agent", + urlKey: "alpha", + role: "engineer", + title: null, + icon: null, + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +describe("AgentActionButtons", () => { + let container: HTMLDivElement; + let root: ReturnType | null; + let queryClient: QueryClient; + let invalidateQueries: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + mockAgentsApi.clearError.mockResolvedValue(makeAgent({ status: "idle" })); + mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" })); + mockAgentsApi.resume.mockResolvedValue(makeAgent({ status: "idle" })); + mockAgentsApi.invoke.mockResolvedValue({ id: "run-1" }); + mockAgentsApi.resetSession.mockResolvedValue(undefined); + }); + + afterEach(async () => { + const currentRoot = root; + if (currentRoot) { + await act(async () => { + currentRoot.unmount(); + }); + } + queryClient.clear(); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + function render(agent: Agent) { + root = createRoot(container); + root.render( + + + , + ); + } + + it("replaces the pause slot with Clear error for error agents", async () => { + render(makeAgent({ status: "error" })); + await flushReact(); + + expect(container.textContent).toContain("Clear error"); + expect(container.textContent).not.toContain("Pause"); + expect(container.textContent).toContain("Run Heartbeat"); + + await act(async () => { + container.querySelector('[aria-label="Open actions for Alpha Agent"]')?.click(); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Reset Sessions"); + }); + + it("calls clearError and refreshes agent-related queries", async () => { + render(makeAgent({ status: "error" })); + await flushReact(); + + await act(async () => { + container.querySelector('[aria-label="Clear error and return agent to idle"]')?.click(); + }); + await flushReact(); + + expect(mockAgentsApi.clearError).toHaveBeenCalledWith("agent-1", "company-1"); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "runtime-state", "agent-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "task-sessions", "agent-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["live-runs", "company-1"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["heartbeats", "company-1", "agent-1"] }); + }); + + it("keeps the normal pause action for non-error agents", async () => { + render(makeAgent({ status: "active" })); + await flushReact(); + + expect(container.textContent).toContain("Pause"); + expect(container.textContent).not.toContain("Clear error"); + }); +}); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 48c12c67..56435f97 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -10,6 +10,7 @@ import { Copy, RotateCcw, Trash2, + CheckCircle2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -85,6 +86,30 @@ export function PauseResumeButton({ ); } +export function ClearErrorButton({ + onClick, + disabled, + size = "sm", +}: { + onClick: () => void; + disabled?: boolean; + size?: "sm" | "default"; +}) { + return ( + + ); +} + function duplicateInstructionFilePath( _bundle: AgentInstructionsBundle, summary: AgentInstructionsFileSummary, @@ -162,6 +187,7 @@ export function AgentActionButtons({ const resolvedCompanyId = companyId ?? agent.companyId; const canonicalAgentRef = agentRouteRef(agent); const isPaused = agent.status === "paused"; + const isError = agent.status === "error"; const reportError = useCallback( (message: string) => { @@ -187,11 +213,12 @@ export function AgentActionButtons({ }, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]); const agentAction = useMutation({ - mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => { + mutationFn: async (action: "invoke" | "pause" | "resume" | "clear_error" | "approve" | "terminate") => { switch (action) { case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined); case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined); case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined); + case "clear_error": return agentsApi.clearError(agent.id, resolvedCompanyId ?? undefined); case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined); case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined); } @@ -265,6 +292,7 @@ export function AgentActionButtons({ const disabled = actionsDisabled || agentAction.isPending; const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled; const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled); + const clearErrorDisabled = disabled; return (
@@ -284,13 +312,21 @@ export function AgentActionButtons({ label={runLabel} size={size} /> - agentAction.mutate("pause")} - onResume={() => agentAction.mutate("resume")} - disabled={pauseResumeDisabled} - size={size} - /> + {isError ? ( + agentAction.mutate("clear_error")} + disabled={clearErrorDisabled} + size={size} + /> + ) : ( + agentAction.mutate("pause")} + onResume={() => agentAction.mutate("resume")} + disabled={pauseResumeDisabled} + size={size} + /> + )} {showStatus && ( diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 26942704..0b8dbe87 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -28,6 +28,7 @@ function PropertyRow({ label, children }: { label: string; children: React.React export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { const { selectedCompanyId } = useCompany(); + const lastErrorIsActive = agent.status === "error"; const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -67,8 +68,16 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {runtimeState?.lastError && ( - - {runtimeState.lastError} + + + {runtimeState.lastError} + )} {agent.lastHeartbeatAt && ( diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index 2ff3ee02..cf689395 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -57,6 +57,7 @@ const ACTIVITY_ROW_VERBS: Record = { "agent.updated": "updated", "agent.paused": "paused", "agent.resumed": "resumed", + "agent.error_cleared": "cleared error on", "agent.terminated": "terminated", "agent.key_created": "created API key for", "agent.budget_updated": "updated budget for", @@ -119,6 +120,7 @@ const ISSUE_ACTIVITY_LABELS: Record = { "agent.updated": "updated the agent", "agent.paused": "paused the agent", "agent.resumed": "resumed the agent", + "agent.error_cleared": "cleared the agent error", "agent.terminated": "terminated the agent", "heartbeat.invoked": "invoked a heartbeat", "heartbeat.cancelled": "cancelled a heartbeat",