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, }); }