fix(panel): agent detail survives a stopped agent — fatal card only on a real roster miss

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.
This commit is contained in:
Renn F
2026-07-15 15:58:29 +02:00
parent 59ff610805
commit fb02ae726f
3 changed files with 143 additions and 25 deletions
@@ -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: () => <div data-testid="agent-status-cards" />,
ResolveWaitDialog: () => null,
AgentStreamViewer: () => null,
AgentActivityPanel: () => null,
AgentActivityPanel: () => <div data-testid="agent-activity-panel" />,
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<typeof useAgentStatus>);
render(<AgentDetailPage />);
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<typeof useAgentDefinition>);
vi.mocked(useAgentStatus).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("Agent fe-dev-2 not found"),
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAgentStatus>);
render(<AgentDetailPage />);
// 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<typeof useAgentDefinition>);
vi.mocked(useAgentStatus).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("Agent not found"),
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAgentStatus>);
render(<AgentDetailPage />);
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<typeof useAgentDefinition>);
vi.mocked(useAgentStatus).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("Agent not found"),
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAgentStatus>);
render(<AgentDetailPage />);
// 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();
});
});
@@ -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 (
<div className="space-y-6">
<Button variant="ghost" onClick={() => router.back()}>
@@ -132,6 +147,11 @@ export default function AgentDetailPage() {
);
}
// Live status unreachable (stopped/unreachable agent, 404 from the
// orchestrator) but the agent identity is real — degrade in place instead
// of replacing the page.
const isAgentDown = !!statusError && !isStatusLoading;
const isActive =
agent &&
["running", "ready", "starting", "waiting_long"].includes(agent.state);
@@ -201,7 +221,7 @@ export default function AgentDetailPage() {
History exists independent of live status, so render for any slug. */}
<AgentActivityPanel agentSlug={agentId} agentUuid={definition?.uuid} />
{isLoading ? (
{isStatusLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
@@ -214,6 +234,29 @@ export default function AgentDetailPage() {
</Card>
))}
</div>
) : isAgentDown ? (
<Card className="border-muted-foreground/30">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<span>Not running</span>
</div>
<p className="text-muted-foreground mt-2">
Live status is unavailable this agent isn&apos;t currently
active. Spawn it to see live state.
</p>
<SpawnAgentDialog
agentId={agentId}
agentName={displayName}
trigger={
<Button className="mt-4">
<Play className="h-4 w-4 mr-2" />
Spawn Agent
</Button>
}
/>
</CardContent>
</Card>
) : agent ? (
<>
{/* Status Cards */}
+3
View File
@@ -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,
});
}