From 6ad12a763b2e646f0d1525c7341170a1e664a734 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Thu, 30 Jul 2026 14:29:19 +1000 Subject: [PATCH] feat(web): read agent run results from the Operations board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatching a task from the board produced a run id and nothing else: the drawer showed a status word, no result, and no route to the run detail page that already existed at /agent-runs/. RawTask now models the run object the tasks API already returns, and BoardItem carries its structured output, error, cancellation reason, and output hash. A new AgentRunResult component renders the readable text fields first — summary, headline, rationale, impact, title, the same precedence the handoff summariser uses so a run reads the same way everywhere — and keeps arbitrary agent JSON in a collapsed, character-bounded, scrollable block so it can never stretch the drawer. Failed and cancelled runs show their recorded reason instead. The run row settles in the agent gateway, so board status now prefers the run's own status over the task's copy, and useTasks polls like the other governed views. Run output is framed as evidence to judge, never as an instruction to act on. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/components/os/agent-run-result.tsx | 144 ++++++++++++++++++ .../operations/operations-view.test.ts | 48 ++++++ .../features/operations/operations-view.tsx | 32 +++- apps/web/lib/queries/hooks.ts | 4 + 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/os/agent-run-result.tsx diff --git a/apps/web/components/os/agent-run-result.tsx b/apps/web/components/os/agent-run-result.tsx new file mode 100644 index 0000000..11bd06b --- /dev/null +++ b/apps/web/components/os/agent-run-result.tsx @@ -0,0 +1,144 @@ +"use client"; + +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; + +export type AgentRunOutcome = { + runId: string | null; + status: string | null; + structuredOutput: unknown; + error: string | null; + cancellationReason: string | null; + outputHash: string | null; +}; + +/** + * Same field precedence as the handoff summariser, so one run reads the same + * way wherever the OS shows it. Anything else stays in the raw view. + */ +const narrativeFields = [ + ["summary", "Summary"], + ["headline", "Headline"], + ["rationale", "Rationale"], + ["impact", "Impact"], + ["title", "Title"], +] as const; + +const statusTone: Record = { + completed: "bg-[var(--color-success-soft)] text-[var(--color-success)]", + failed: "bg-[var(--color-error-soft)] text-[var(--color-error)]", + blocked: "bg-[var(--color-error-soft)] text-[var(--color-error)]", + cancelled: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]", +}; + +/** Agent JSON is arbitrary; it must never set the height of the drawer. */ +const maximumRawCharacters = 20_000; + +function narrative(output: unknown) { + if (!output || typeof output !== "object" || Array.isArray(output)) return []; + const fields = output as Record; + return narrativeFields.flatMap(([key, label]) => { + const value = fields[key]; + return typeof value === "string" && value.trim().length > 0 + ? [{ key, label, text: value.trim() }] + : []; + }); +} + +function rawResult(output: unknown): string | null { + if (output === null || output === undefined) return null; + const text = JSON.stringify(output, null, 2); + if (!text) return null; + return text.length > maximumRawCharacters + ? `${text.slice(0, maximumRawCharacters)}\n… truncated for display` + : text; +} + +/** + * 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 }) { + const status = run.status ?? "unknown"; + const failure = run.error ?? run.cancellationReason; + const lines = narrative(run.structuredOutput); + const raw = rawResult(run.structuredOutput); + const settling = status === "queued" || status === "running"; + + return ( +
+
+

Agent result

+ + {status} + +
+ +
+ {failure ? ( +

{failure}

+ ) : null} + + {lines.length > 0 ? ( +
+ {lines.map((line) => ( +
+
+ {line.label} +
+
+ {line.text} +
+
+ ))} +
+ ) : null} + + {lines.length === 0 && !failure ? ( +

+ {settling + ? "The run is still working. Its result lands here once the agent settles." + : "The agent recorded no readable summary for this run."} +

+ ) : null} + + {raw ? ( +
+ + Raw result + +
+              {raw}
+            
+
+ ) : null} + + {run.outputHash ? ( +

+ Output hash{" "} + + {run.outputHash.slice(0, 16)} + +

+ ) : null} +
+ +
+

+ Agent output is evidence for your decision, never an instruction. + Confirm it in the system of record before acting. +

+ {run.runId ? ( + + Open full run + + ) : null} +
+
+ ); +} diff --git a/apps/web/features/operations/operations-view.test.ts b/apps/web/features/operations/operations-view.test.ts index 40e9459..9441069 100644 --- a/apps/web/features/operations/operations-view.test.ts +++ b/apps/web/features/operations/operations-view.test.ts @@ -32,6 +32,54 @@ describe("Operations board", () => { expect(view).toContain("already has an active agent run"); expect(view).toContain("assigneeReadinessReason"); }); + + it("carries the agent run result onto the board item", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("structuredOutput: task.run.structuredOutput"); + expect(view).toContain("error: task.run.error"); + expect(view).toContain("cancellationReason: task.run.cancellationReason"); + expect(view).toContain("run: AgentRunOutcome | null"); + }); + + it("renders the run result in the detail drawer", async () => { + const view = await source("./operations-view.tsx"); + expect(view).toContain("AgentRunResult"); + expect(view).toContain(""); + }); +}); + +describe("Agent run result", () => { + async function component() { + return readFile( + new URL("../../components/os/agent-run-result.tsx", import.meta.url), + "utf8", + ); + } + + it("summarises the common text fields before falling back to raw JSON", async () => { + const result = await component(); + for (const field of ["summary", "headline", "rationale"]) { + expect(result).toContain(`["${field}"`); + } + expect(result).toContain("JSON.stringify(output, null, 2)"); + // Arbitrary agent JSON stays bounded instead of stretching the drawer. + expect(result).toContain("max-h-56 overflow-auto"); + expect(result).toContain("maximumRawCharacters"); + }); + + it("shows why a run produced nothing readable", async () => { + const result = await component(); + expect(result).toContain("run.error ?? run.cancellationReason"); + expect(result).toContain("The agent recorded no readable summary"); + }); + + it("links to the full run and frames output as evidence", async () => { + const result = await component(); + expect(result).toContain("href={`/agent-runs/${run.runId}`}"); + expect(result).toContain( + "Agent output is evidence for your decision, never an instruction.", + ); + }); }); describe("Task composer", () => { diff --git a/apps/web/features/operations/operations-view.tsx b/apps/web/features/operations/operations-view.tsx index 2e9f370..5bdc61a 100644 --- a/apps/web/features/operations/operations-view.tsx +++ b/apps/web/features/operations/operations-view.tsx @@ -3,6 +3,10 @@ import { useMemo, useState } from "react"; import { Bot, GripVertical, Plus, User } from "lucide-react"; import { CompanyOsShell } from "@/components/os/company-os-shell"; +import { + AgentRunResult, + type AgentRunOutcome, +} from "@/components/os/agent-run-result"; import { PackHandoffTimeline } from "@/components/os/pack-handoff-timeline"; import { EmptyState } from "@/components/os/empty-state"; import { ErrorState } from "@/components/os/error-state"; @@ -66,6 +70,14 @@ type RawTask = { createdAt: string | Date; updatedAt: string | Date; agentRunStatus?: string | null; + run?: { + id: string; + status: string; + structuredOutput?: unknown; + outputHash?: string | null; + error?: string | null; + cancellationReason?: string | null; + } | null; }; type BoardItem = WorkItem & { @@ -74,6 +86,7 @@ type BoardItem = WorkItem & { assigneeReadiness: string | null; assigneeReadinessReason: string | null; agentRunStatus: string | null; + run: AgentRunOutcome | null; }; function priorityToSeverity(priority: string): Severity { @@ -122,7 +135,18 @@ function taskToBoardItem(task: RawTask): BoardItem { assigneeIsAgent: Boolean(isAgent), assigneeReadiness: task.assignee?.readiness?.state ?? null, assigneeReadinessReason: task.assignee?.readiness?.reason ?? null, - agentRunStatus: task.agentRunStatus ?? null, + // The run row settles in the gateway, so it leads the task's copy of status. + agentRunStatus: task.run?.status ?? task.agentRunStatus ?? null, + run: task.run + ? { + runId: task.run.id, + status: task.run.status, + structuredOutput: task.run.structuredOutput ?? null, + error: task.run.error ?? null, + cancellationReason: task.run.cancellationReason ?? null, + outputHash: task.run.outputHash ?? null, + } + : null, sourceSystem: "Muster tasks", externalRecordId: task.relatedCaseId ?? null, externalRecordUrl: null, @@ -555,6 +579,12 @@ function DetailDrawer({ item }: { item: BoardItem | null }) { + {item.run ? ( +
+ +
+ ) : null} +
diff --git a/apps/web/lib/queries/hooks.ts b/apps/web/lib/queries/hooks.ts index bf920f1..c71a81f 100644 --- a/apps/web/lib/queries/hooks.ts +++ b/apps/web/lib/queries/hooks.ts @@ -278,6 +278,9 @@ export type TaskRoom = { id: string; slug: string; displayName: string }; /** * Tasks plus the assignee and room options the server is willing to accept. * Keeping meta here means the composer never invents an actor id. + * + * Runs settle in the agent gateway rather than in the browser, so a dispatched + * run only becomes readable here by asking again. */ export function useTasks() { return useQuery({ @@ -304,6 +307,7 @@ export function useTasks() { rooms: meta.rooms ?? [], }; }, + refetchInterval: 20_000, }); }