From fb02ae726f529d3375306a974d915ad088e3e72b Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 15 Jul 2026 15:58:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(panel):=20agent=20detail=20survives=20a=20s?= =?UTF-8?q?topped=20agent=20=E2=80=94=20fatal=20card=20only=20on=20a=20rea?= =?UTF-8?q?l=20roster=20miss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /orchestrator/agents/{id} 404s for any non-running container, and the page's error early-return unmounted the DB-backed header/activity content #529 had placed behind it. The fatal card now gates on the roster identity lookup; a live-status error degrades in place to a not-running banner + spawn dialog, with retry disabled on the deterministic 404. --- .../agents/[agentId]/__tests__/page.test.tsx | 114 ++++++++++++++---- .../app/(dashboard)/agents/[agentId]/page.tsx | 51 +++++++- panel/src/hooks/use-agents.ts | 3 + 3 files changed, 143 insertions(+), 25 deletions(-) diff --git a/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx b/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx index f25643c2..22922e66 100644 --- a/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx +++ b/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx @@ -6,6 +6,13 @@ import { render, screen } from "@testing-library/react"; // spawnAgent.mutateAsync({ agentId }) directly from a bare button — no task, // no message, no double-fire guard. Both bare buttons must now render the // shared SpawnAgentDialog instead. +// +// Whole-page-replaced-by-error fix: a stopped agent's live-status query 404s +// (orchestrator has no running instance) while the agent's roster identity +// still resolves fine. That must degrade the live-status area only — not +// discard the DB-backed header/activity content already rendered above it. +// The full-page fatal card is reserved for a genuinely invalid agent id +// (the roster lookup itself failing). vi.mock("next/navigation", () => ({ useParams: () => ({ agentId: "fe-dev-2" }), @@ -24,15 +31,19 @@ vi.mock("@/hooks/use-page-refresh", () => ({ vi.mock("@/hooks/use-agents", () => ({ useAgentStatus: vi.fn(), - useAgentDefinition: vi.fn(() => ({ data: undefined })), + useAgentDefinition: vi.fn(() => ({ + data: undefined, + isLoading: false, + error: undefined, + })), useStopAgent: vi.fn(() => ({ mutateAsync: vi.fn() })), })); vi.mock("@/components/agents", () => ({ - AgentStatusCards: () => null, + AgentStatusCards: () =>
, ResolveWaitDialog: () => null, AgentStreamViewer: () => null, - AgentActivityPanel: () => null, + AgentActivityPanel: () =>
, SpawnAgentDialog: ({ agentId, agentName, @@ -52,27 +63,10 @@ vi.mock("@/components/agents", () => ({ ), })); -import { useAgentStatus } from "@/hooks/use-agents"; +import { useAgentStatus, useAgentDefinition } from "@/hooks/use-agents"; import AgentDetailPage from "../page"; describe("AgentDetailPage — spawn dialog parity", () => { - it("renders SpawnAgentDialog (not a bare button) in the error state", () => { - vi.mocked(useAgentStatus).mockReturnValue({ - data: undefined, - isLoading: false, - error: new Error("not found"), - refetch: vi.fn(), - } as unknown as ReturnType); - - render(); - - const dialog = screen.getByTestId("spawn-agent-dialog"); - expect(dialog).toHaveAttribute("data-agent-id", "fe-dev-2"); - expect( - screen.getByRole("button", { name: /Spawn Agent/i }), - ).toBeInTheDocument(); - }); - it("renders SpawnAgentDialog in the header when the agent is not active", () => { vi.mocked(useAgentStatus).mockReturnValue({ data: { @@ -99,3 +93,81 @@ describe("AgentDetailPage — spawn dialog parity", () => { ).not.toBeInTheDocument(); }); }); + +describe("AgentDetailPage — live-status error degrades, doesn't discard content", () => { + it("keeps header + activity panel and shows a not-running banner when the roster resolves but live status 404s", () => { + vi.mocked(useAgentDefinition).mockReturnValue({ + data: { id: "fe-dev-2", uuid: "uuid-1", name: "Frontend Dev 2" }, + isLoading: false, + error: undefined, + } as unknown as ReturnType); + vi.mocked(useAgentStatus).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("Agent fe-dev-2 not found"), + refetch: vi.fn(), + } as unknown as ReturnType); + + render(); + + // DB-backed content that loaded independently of live status must stay. + expect(screen.getByText("Frontend Dev 2")).toBeInTheDocument(); + expect(screen.getByTestId("agent-activity-panel")).toBeInTheDocument(); + + // Live-status area degrades to an inline banner, not a whole-page card. + expect(screen.getByText("Not running")).toBeInTheDocument(); + expect( + screen.queryByText("Failed to load agent status"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-status-cards")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Spawn Agent/i }), + ).toBeInTheDocument(); + }); + + it("shows the whole-page fatal card only when the roster lookup itself fails (invalid id)", () => { + vi.mocked(useAgentDefinition).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("Agent not found"), + } as unknown as ReturnType); + vi.mocked(useAgentStatus).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("Agent not found"), + refetch: vi.fn(), + } as unknown as ReturnType); + + render(); + + expect(screen.getByText("Failed to load agent status")).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-activity-panel"), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Spawn Agent/i }), + ).toBeInTheDocument(); + }); + + it("shows the status skeleton (not the banner) while the roster is still loading, even if status already errored", () => { + vi.mocked(useAgentDefinition).mockReturnValue({ + data: undefined, + isLoading: true, + error: undefined, + } as unknown as ReturnType); + vi.mocked(useAgentStatus).mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("Agent not found"), + refetch: vi.fn(), + } as unknown as ReturnType); + + render(); + + // Definition still resolving — not fatal yet, page renders normally. + expect( + screen.queryByText("Failed to load agent status"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Not running")).toBeInTheDocument(); + }); +}); diff --git a/panel/src/app/(dashboard)/agents/[agentId]/page.tsx b/panel/src/app/(dashboard)/agents/[agentId]/page.tsx index 84758fcf..5d5abd54 100644 --- a/panel/src/app/(dashboard)/agents/[agentId]/page.tsx +++ b/panel/src/app/(dashboard)/agents/[agentId]/page.tsx @@ -65,8 +65,17 @@ export default function AgentDetailPage() { const router = useRouter(); const agentId = params.agentId as string; - const { data: agent, isLoading, error, refetch } = useAgentStatus(agentId); - const { data: definition } = useAgentDefinition(agentId); + const { + data: agent, + isLoading: isStatusLoading, + error: statusError, + refetch, + } = useAgentStatus(agentId); + const { + data: definition, + isLoading: isDefinitionLoading, + error: definitionError, + } = useAgentDefinition(agentId); const { register, unregister } = usePageRefresh(); @@ -100,7 +109,13 @@ export default function AgentDetailPage() { } }; - if (error) { + // Fatal only when the agent identity itself doesn't resolve (roster lookup + // failed) — a genuinely invalid id. A live-status error (agent not running) + // is a normal, expected state for a stopped agent and must not discard the + // DB-backed content below (activity panel, header) — see isAgentDown. + const isInvalidAgent = !!definitionError && !isDefinitionLoading; + + if (isInvalidAgent) { return (
+ } + /> + + ) : agent ? ( <> {/* Status Cards */} diff --git a/panel/src/hooks/use-agents.ts b/panel/src/hooks/use-agents.ts index a619df81..f97bc4a3 100644 --- a/panel/src/hooks/use-agents.ts +++ b/panel/src/hooks/use-agents.ts @@ -337,6 +337,9 @@ export function useAgentStatus(agentId: string) { queryFn: () => orchestratorApi.getAgentStatus(agentId), enabled: !!agentId, refetchInterval: 5000, // Refetch every 5 seconds + // A 404 (agent not running) is deterministic, not transient — retrying + // just delays settling into the degraded "not running" state. + retry: false, }); }