diff --git a/apps/web/app/api/v1/agent-runs/[id]/timeline/route.ts b/apps/web/app/api/v1/agent-runs/[id]/timeline/route.ts index 2ad8608..82a38d4 100644 --- a/apps/web/app/api/v1/agent-runs/[id]/timeline/route.ts +++ b/apps/web/app/api/v1/agent-runs/[id]/timeline/route.ts @@ -38,6 +38,12 @@ export async function GET( status: schema.agentRuns.status, startedAt: schema.agentRuns.startedAt, completedAt: schema.agentRuns.completedAt, + // Without these a failed run reads as a bare status with no cause. + failureCode: schema.agentRuns.failureCode, + error: schema.agentRuns.error, + cancellationReason: schema.agentRuns.cancellationReason, + structuredOutput: schema.agentRuns.structuredOutput, + outputHash: schema.agentRuns.outputHash, }) .from(schema.agentRuns) .where( diff --git a/apps/web/components/agent-run-view.tsx b/apps/web/components/agent-run-view.tsx index fbb6c7d..8da1873 100644 --- a/apps/web/components/agent-run-view.tsx +++ b/apps/web/components/agent-run-view.tsx @@ -1,112 +1,164 @@ "use client"; -import { useEffect, useState } from "react"; -import { OpsShell } from "@/components/ops-shell"; +import { useQuery } from "@tanstack/react-query"; +import { CompanyOsShell } from "@/components/os/company-os-shell"; +import { AgentRunResult } from "@/components/os/agent-run-result"; +import { ErrorState } from "@/components/os/error-state"; +import { PageBody } from "@/components/os/page-body"; +import { SkeletonRows } from "@/components/os/skeleton"; import { PageHeader } from "@/components/page-header"; import { Badge } from "@/components/ui/badge"; +import { apiGet } from "@/lib/api/client"; +import { relativeTime } from "@/lib/utils"; -type HarnessRun = { - protocolVersion: "muster.agent-harness/v1"; +type RunTimeline = { runId: string; status: string; - agentKey: string; - correlationId: string; - duplicate: boolean; - result: unknown; + startedAt: string | null; + completedAt: string | null; + failureCode: string | null; + error: string | null; + cancellationReason: string | null; + structuredOutput: unknown; + outputHash: string | null; + events: Array<{ + id: string; + eventType: string; + message: string; + createdAt: string; + }>; }; +const IN_FLIGHT = [ + "queued", + "running", + "awaiting_approval", + "waiting_sources", +]; + export function AgentRunView({ runId }: { runId: string }) { - const [run, setRun] = useState(null); - const [error, setError] = useState(""); + const run = useQuery({ + queryKey: ["agent-run", runId, "timeline"], + queryFn: async () => { + const res = await apiGet( + `/api/v1/agent-runs/${encodeURIComponent(runId)}/timeline`, + ); + return res.data; + }, + // A run settles in the gateway, not the browser, so poll until it stops. + refetchInterval: (query) => + IN_FLIGHT.includes(query.state.data?.status ?? "") ? 10_000 : false, + }); - useEffect(() => { - const controller = new AbortController(); - void fetch(`/api/v1/agent-harness/runs/${encodeURIComponent(runId)}`, { - cache: "no-store", - signal: controller.signal, - }) - .then(async (response) => { - const body = (await response.json()) as { - data?: HarnessRun; - detail?: string; - }; - if (!response.ok || !body.data) - throw new Error(body.detail ?? "Agent run is unavailable."); - setRun(body.data); - }) - .catch((cause: unknown) => { - if (!controller.signal.aborted) - setError( - cause instanceof Error - ? cause.message - : "Agent run is unavailable.", - ); - }); - return () => controller.abort(); - }, [runId]); + const data = run.data; return ( - + -
-
- {run && ( - <> -
-
-

- {run.agentKey} -

- {run.status} + + {run.isError ? ( + void run.refetch()} /> + ) : null} + {run.isLoading ? : null} + + {data ? ( + <> +
+
+

Run

+ + {data.status} + + {data.failureCode ? ( + + {data.failureCode} + + ) : null} +
+
+
+
+ Run id +
+
+ {data.runId} +
+
+
+
+ Started +
+
+ {data.startedAt ? relativeTime(data.startedAt) : "—"} +
+
+
+
+ Completed +
+
+ {data.completedAt ? relativeTime(data.completedAt) : "—"} +
-
-
-
- Run -
-
- {run.runId} -
-
-
-
- Correlation -
-
- {run.correlationId} -
-
-
-
-
-

Typed result

-
-                  {run.result === null
-                    ? "No typed result is available yet."
-                    : JSON.stringify(run.result, null, 2)}
-                
-
- - )} - {!run && !error && ( -

- Loading agent run… -

- )} - {error && ( -

- {error} -

- )} -
-
- +
+
+ Events +
+
{data.events.length}
+
+ + + + + +
+
+

Execution timeline

+
+ {data.events.length === 0 ? ( +

+ No execution events were recorded for this run. +

+ ) : ( +
    + {data.events.map((event) => ( +
  1. +
    + + {event.eventType} + + + {relativeTime(event.createdAt)} + +
    +

    + {event.message} +

    +
  2. + ))} +
+ )} +
+ + ) : null} + + ); } diff --git a/apps/web/components/agent-surfaces.test.ts b/apps/web/components/agent-surfaces.test.ts new file mode 100644 index 0000000..224dc9c --- /dev/null +++ b/apps/web/components/agent-surfaces.test.ts @@ -0,0 +1,52 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +async function source(name: string) { + return readFile(new URL(name, import.meta.url), "utf8"); +} + +describe("agent detail tabs", () => { + it("only lists tabs that render their own content", async () => { + const view = await source("./agents-view.tsx"); + expect(view).toContain('const agentTabs = ["Overview", "Learning"];'); + // Every listed tab must have a branch, or it silently shows Overview again. + for (const dead of [ + '"Instructions"', + '"Tools"', + '"Permissions"', + '"Rooms"', + '"Evaluations"', + '"Versions"', + ]) { + expect(view).not.toContain(` ${dead},`); + } + }); +}); + +describe("agent run detail", () => { + it("shows why a run failed instead of only its status", async () => { + const view = await source("./agent-run-view.tsx"); + expect(view).toContain("failureCode"); + expect(view).toContain("cancellationReason"); + expect(view).toContain("AgentRunResult"); + }); + + it("renders the execution timeline the route already exposed", async () => { + const view = await source("./agent-run-view.tsx"); + expect(view).toContain("/timeline"); + expect(view).toContain("Execution timeline"); + const route = await source( + "../app/api/v1/agent-runs/[id]/timeline/route.ts", + ); + expect(route).toContain("failureCode: schema.agentRuns.failureCode"); + expect(route).toContain("error: schema.agentRuns.error"); + }); + + it("uses the OS shell and does not link to itself", async () => { + const view = await source("./agent-run-view.tsx"); + expect(view).toContain("CompanyOsShell"); + expect(view).toContain("PageBody"); + expect(view).not.toContain("OpsShell"); + expect(view).toContain("showFullRunLink={false}"); + }); +}); diff --git a/apps/web/components/agents-view.tsx b/apps/web/components/agents-view.tsx index aa91412..2103af6 100644 --- a/apps/web/components/agents-view.tsx +++ b/apps/web/components/agents-view.tsx @@ -227,18 +227,15 @@ export function AgentsView() { ); } -const agentTabs = [ - "Overview", - "Instructions", - "Tools", - "Permissions", - "Rooms", - "Runs", - "Learning", - "Evaluations", - "Versions", - "Audit", -]; +/** + * Only tabs that render distinct content. Instructions, Tools, Permissions, + * Rooms, Runs, Evaluations, Versions, and Audit all fell through to the + * Overview panel, so eight links looked navigable and silently showed the + * same page. Overview already carries the permission, runtime, and tool + * evidence the readiness payload actually provides; the rest need APIs that + * do not exist yet. Add a tab back when it has something of its own to show. + */ +const agentTabs = ["Overview", "Learning"]; export function AgentDetailView({ agentId, diff --git a/apps/web/components/os/agent-run-result.tsx b/apps/web/components/os/agent-run-result.tsx index 11bd06b..3b3f7d8 100644 --- a/apps/web/components/os/agent-run-result.tsx +++ b/apps/web/components/os/agent-run-result.tsx @@ -58,7 +58,14 @@ function rawResult(output: unknown): string | null { * Read-only view of what an agent returned for one work item. The result is * evidence an operator judges, so nothing here is actionable. */ -export function AgentRunResult({ run }: { run: AgentRunOutcome }) { +export function AgentRunResult({ + run, + showFullRunLink = true, +}: { + run: AgentRunOutcome; + /** The run detail page renders this panel too; it must not link to itself. */ + showFullRunLink?: boolean; +}) { const status = run.status ?? "unknown"; const failure = run.error ?? run.cancellationReason; const lines = narrative(run.structuredOutput); @@ -130,7 +137,7 @@ export function AgentRunResult({ run }: { run: AgentRunOutcome }) { Agent output is evidence for your decision, never an instruction. Confirm it in the system of record before acting.

- {run.runId ? ( + {run.runId && showFullRunLink ? (