diff --git a/src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts b/src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts new file mode 100644 index 0000000000..2cfb3c6072 --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts @@ -0,0 +1,149 @@ +/** + * Pure model to compare two Orchestration Canvas history runs side by side (History tab, + * PR-A / Task A1). No React, no side effects. + * + * `normalizeRunSide` turns a `HistoryItem` plus its raw detail payload (the JSON body of + * `GET /api/a2a/tasks/[id]` for `source === "a2a"`, or the in-memory `CloudAgentTask` for + * `source === "cloud-agent"`) into a `RunSide` with a normalized `events` timeline and + * `memoryHits` list. `detail` is untrusted: for A2A it round-trips through + * `reconstituteHistoricalTask` (`src/app/api/a2a/tasks/[id]/route.ts`) plus client-supplied + * `metadata` with no Zod validation, so every field is read defensively — a malformed shape + * drops the offending item (or yields `[]`) instead of throwing. See Fase 2 lesson in + * `historyModel.ts` / global-constraints: `memoryHits: "boom"` used to crash the drawer. + * + * `buildComparison` derives signed deltas (`right - left`) for `durationMs`/`cost` only when + * both sides have a finite value, and always reports `eventCount`'s delta and whether the two + * sides share the same (source, identity) pair. + */ +import type { HistoryItem } from "./historyModel"; + +export interface RunEvent { + label: string; + timestamp: string | null; +} + +export interface RunMemoryHit { + id: string; + key: string; + type: string; + snippet: string; +} + +export interface RunSide { + item: HistoryItem; + events: RunEvent[]; + memoryHits: RunMemoryHit[]; +} + +export interface RunDeltas { + durationMs: number | null; + cost: number | null; + eventCount: number; + sameIdentity: boolean; +} + +export interface RunComparison { + left: RunSide; + right: RunSide; + deltas: RunDeltas; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** `{ timestamp: string; state: string; message?: string }` — reconstituteHistoricalTask's shape. */ +function a2aEventsFrom(detail: Record): RunEvent[] { + const events = detail.events; + if (!Array.isArray(events)) return []; + const out: RunEvent[] = []; + for (const raw of events) { + if (!isRecord(raw)) continue; + const { state, message, timestamp } = raw; + if (!isNonEmptyString(state)) continue; + if (message !== undefined && typeof message !== "string") continue; + if (timestamp !== undefined && timestamp !== null && typeof timestamp !== "string") continue; + out.push({ + label: typeof message === "string" ? message : state, + timestamp: typeof timestamp === "string" ? timestamp : null, + }); + } + return out; +} + +/** `CloudAgentActivity[]` (`src/lib/cloudAgent/types.ts`) — each item has `type` and `content`. */ +function cloudAgentEventsFrom(detail: Record): RunEvent[] { + const activities = detail.activities; + if (!Array.isArray(activities)) return []; + const out: RunEvent[] = []; + for (const raw of activities) { + if (!isRecord(raw)) continue; + const { content, timestamp } = raw; + if (!isNonEmptyString(content)) continue; + if (timestamp !== undefined && timestamp !== null && typeof timestamp !== "string") continue; + out.push({ + label: content, + timestamp: typeof timestamp === "string" ? timestamp : null, + }); + } + return out; +} + +function memoryHitsFrom(detail: Record): RunMemoryHit[] { + const metadata = detail.metadata; + if (!isRecord(metadata)) return []; + const hits = metadata.memoryHits; + if (!Array.isArray(hits)) return []; + const out: RunMemoryHit[] = []; + for (const raw of hits) { + if (!isRecord(raw)) continue; + const { id, key, type, snippet } = raw; + if ( + typeof id === "string" && + typeof key === "string" && + typeof type === "string" && + typeof snippet === "string" + ) { + out.push({ id, key, type, snippet }); + } + } + return out; +} + +/** + * Builds one side of a comparison. NEVER throws — `detail` is untrusted JSON (persisted rows + * for A2A history, client-supplied `metadata` with no Zod) so any shape mismatch just drops + * the offending item; a missing/non-object `detail` yields empty `events`/`memoryHits`. + */ +export function normalizeRunSide(item: HistoryItem, detail: unknown): RunSide { + if (!isRecord(detail)) { + return { item, events: [], memoryHits: [] }; + } + const events = item.source === "a2a" ? a2aEventsFrom(detail) : cloudAgentEventsFrom(detail); + const memoryHits = item.source === "a2a" ? memoryHitsFrom(detail) : []; + return { item, events, memoryHits }; +} + +function signedDelta(left: number | null, right: number | null): number | null { + if (!Number.isFinite(left) || !Number.isFinite(right)) return null; + return (right as number) - (left as number); +} + +/** Combines two normalized sides into a comparison with signed `right - left` deltas. */ +export function buildComparison(left: RunSide, right: RunSide): RunComparison { + return { + left, + right, + deltas: { + durationMs: signedDelta(left.item.durationMs, right.item.durationMs), + cost: signedDelta(left.item.cost, right.item.cost), + eventCount: right.events.length - left.events.length, + sameIdentity: + left.item.source === right.item.source && left.item.identity === right.item.identity, + }, + }; +} diff --git a/tests/unit/ui/orchestrationCompare.test.ts b/tests/unit/ui/orchestrationCompare.test.ts new file mode 100644 index 0000000000..61c41b5edc --- /dev/null +++ b/tests/unit/ui/orchestrationCompare.test.ts @@ -0,0 +1,98 @@ +/** + * tests/unit/ui/orchestrationCompare.test.ts + * Pure model tests for comparing two Orchestration Canvas history runs (Task A1, PR-A). + * Run: node --import tsx/esm --test tests/unit/ui/orchestrationCompare.test.ts + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildComparison, + normalizeRunSide, +} from "../../../src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts"; + +const a2aItem = { + id: "a2a:1", + source: "a2a" as const, + identity: "smart-routing", + state: "succeeded" as const, + label: "smart-routing", + createdAt: "2026-09-01T10:00:00.000Z", + completedAt: "2026-09-01T10:00:30.000Z", + durationMs: 30000, + cost: null, + raw: {}, +}; +const caItem = { + id: "cloud-agent:9", + source: "cloud-agent" as const, + identity: "jules", + state: "succeeded" as const, + label: "fix login", + createdAt: "2026-09-01T10:00:00.000Z", + completedAt: "2026-09-01T10:00:10.000Z", + durationMs: 10000, + cost: 0.5, + raw: {}, +}; + +test("normalizeRunSide reads a2a events from the historical task shape", () => { + const side = normalizeRunSide(a2aItem, { + events: [ + { timestamp: "2026-09-01T10:00:00.000Z", state: "submitted" }, + { timestamp: "2026-09-01T10:00:05.000Z", state: "working", message: "started" }, + ], + }); + assert.deepEqual(side.events, [ + { label: "submitted", timestamp: "2026-09-01T10:00:00.000Z" }, + { label: "started", timestamp: "2026-09-01T10:00:05.000Z" }, + ]); +}); + +test("normalizeRunSide reads cloud-agent activities", () => { + const side = normalizeRunSide(caItem, { + activities: [ + { id: "a", type: "log", content: "cloning", timestamp: "2026-09-01T10:00:01.000Z" }, + ], + }); + assert.deepEqual(side.events, [{ label: "cloning", timestamp: "2026-09-01T10:00:01.000Z" }]); +}); + +test("normalizeRunSide never throws on malformed detail", () => { + for (const bad of [null, undefined, "boom", 42, { events: "nope" }, { events: [null, 7, {}] }]) { + const side = normalizeRunSide(a2aItem, bad); + assert.deepEqual(side.events, []); + assert.deepEqual(side.memoryHits, []); + } +}); + +test("normalizeRunSide keeps only well-formed memoryHits", () => { + const side = normalizeRunSide(a2aItem, { + events: [], + metadata: { + memoryHits: [ + { id: "m1", key: "k", type: "factual", snippet: "s" }, + { id: "m2", key: { a: 1 }, type: "factual", snippet: "s" }, + "nope", + ], + }, + }); + assert.equal(side.memoryHits.length, 1); + assert.equal(side.memoryHits[0].id, "m1"); +}); + +test("buildComparison computes signed deltas and sameIdentity", () => { + const left = normalizeRunSide(a2aItem, { events: [{ timestamp: null, state: "submitted" }] }); + const right = normalizeRunSide({ ...a2aItem, id: "a2a:2", durationMs: 45000 }, { events: [] }); + const cmp = buildComparison(left, right); + assert.equal(cmp.deltas.durationMs, 15000); + assert.equal(cmp.deltas.eventCount, -1); + assert.equal(cmp.deltas.sameIdentity, true); +}); + +test("buildComparison returns null delta when either side lacks the value", () => { + const left = normalizeRunSide(a2aItem, {}); // cost null + const right = normalizeRunSide(caItem, {}); // cost 0.5 + const cmp = buildComparison(left, right); + assert.equal(cmp.deltas.cost, null); + assert.equal(cmp.deltas.sameIdentity, false); +});