Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/web/components/os/agent-run-result.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, unknown>;
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 (
<section className="rounded-md border border-border bg-card">
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="text-sm font-semibold">Agent result</h3>
<Badge
className={statusTone[status] ?? "bg-muted text-muted-foreground"}
>
{status}
</Badge>
</header>

<div className="px-3 py-2">
{failure ? (
<p className="text-xs text-[var(--color-error)]">{failure}</p>
) : null}

{lines.length > 0 ? (
<dl className="space-y-2">
{lines.map((line) => (
<div key={line.key}>
<dt className="text-xs font-semibold uppercase tracking-[0.06em] text-muted-foreground">
{line.label}
</dt>
<dd className="mt-0.5 whitespace-pre-wrap break-words text-xs">
{line.text}
</dd>
</div>
))}
</dl>
) : null}

{lines.length === 0 && !failure ? (
<p className="text-xs text-muted-foreground">
{settling
? "The run is still working. Its result lands here once the agent settles."
: "The agent recorded no readable summary for this run."}
</p>
) : null}

{raw ? (
<details className="mt-2 rounded-md border border-border bg-muted/30 p-2">
<summary className="cursor-pointer text-xs font-semibold">
Raw result
</summary>
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words text-xs leading-5">
{raw}
</pre>
</details>
) : null}

{run.outputHash ? (
<p className="mt-2 text-xs text-muted-foreground">
Output hash{" "}
<span className="break-all font-mono">
{run.outputHash.slice(0, 16)}
</span>
</p>
) : null}
</div>

<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-2">
<p className="text-xs text-muted-foreground">
Agent output is evidence for your decision, never an instruction.
Confirm it in the system of record before acting.
</p>
{run.runId ? (
<Link
href={`/agent-runs/${run.runId}`}
className="text-xs font-semibold underline"
>
Open full run
</Link>
) : null}
</div>
</section>
);
}
48 changes: 48 additions & 0 deletions apps/web/features/operations/operations-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<AgentRunResult run={item.run} />");
});
});

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", () => {
Expand Down
32 changes: 31 additions & 1 deletion apps/web/features/operations/operations-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 & {
Expand All @@ -74,6 +86,7 @@ type BoardItem = WorkItem & {
assigneeReadiness: string | null;
assigneeReadinessReason: string | null;
agentRunStatus: string | null;
run: AgentRunOutcome | null;
};

function priorityToSeverity(priority: string): Severity {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -555,6 +579,12 @@ function DetailDrawer({ item }: { item: BoardItem | null }) {
</div>
</dl>

{item.run ? (
<div className="mt-3">
<AgentRunResult run={item.run} />
</div>
) : null}

<div className="mt-3">
<PackHandoffTimeline taskId={item.id} />
</div>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/lib/queries/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -304,6 +307,7 @@ export function useTasks() {
rooms: meta.rooms ?? [],
};
},
refetchInterval: 20_000,
});
}

Expand Down
Loading