diff --git a/src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts b/src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts new file mode 100644 index 0000000000..3bcd98e623 --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts @@ -0,0 +1,53 @@ +/** A2A tasks → unified orchestration nodes. Pure. */ +import type { A2ATask } from "@/lib/a2a/taskManager"; +import type { OrchEdge, OrchNode, OrchState } from "./orchestrationTypes"; + +const STATE_MAP: Record = { + submitted: "queued", + working: "running", + completed: "succeeded", + failed: "failed", + cancelled: "cancelled", +}; +const TERMINAL: ReadonlySet = new Set(["succeeded", "failed", "cancelled"]); + +function truncate(s: string, n = 60): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +export function fromA2A(tasks: A2ATask[]): { nodes: OrchNode[]; edges: OrchEdge[] } { + if (tasks.length === 0) return { nodes: [], edges: [] }; + const nodes: OrchNode[] = []; + const edges: OrchEdge[] = []; + const counts: Partial> = {}; + + for (const t of tasks) { + const mapped = STATE_MAP[t.state]; + const state: OrchState = mapped ?? "failed"; + counts[state] = (counts[state] ?? 0) + 1; + const id = `a2a:${t.id}`; + const firstUser = t.input.messages.find((m) => m.role === "user")?.content ?? ""; + nodes.push({ + id, + kind: "work", + source: "a2a", + state, + label: t.skill, + sublabel: mapped ? truncate(firstUser) : `unknown state: ${String(t.state)}`, + startedAt: t.createdAt, + updatedAt: t.updatedAt, + endedAt: TERMINAL.has(state) ? t.updatedAt : undefined, + raw: t, + }); + edges.push({ + id: `e:source:a2a→${id}`, + from: "source:a2a", + to: id, + kind: "owns", + active: state === "running", + }); + } + + nodes.unshift({ id: "source:a2a", kind: "source", source: "a2a", label: "A2A", counts }); + return { nodes, edges }; +} diff --git a/tests/unit/ui/orchestrationModel.test.ts b/tests/unit/ui/orchestrationModel.test.ts index 5aa06f771f..37c8f26462 100644 --- a/tests/unit/ui/orchestrationModel.test.ts +++ b/tests/unit/ui/orchestrationModel.test.ts @@ -11,6 +11,8 @@ import { import { STATUS_HEX } from "../../../src/shared/constants/statusColors.ts"; import { fromCloudAgent } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts"; import type { CloudAgentTask } from "../../../src/lib/cloudAgent/types.ts"; +import { fromA2A } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromA2A.ts"; +import type { A2ATask } from "../../../src/lib/a2a/taskManager.ts"; describe("orchestrationTypes", () => { it("covers all six states with a color each", () => { @@ -101,3 +103,44 @@ describe("fromCloudAgent", () => { assert.equal(out.edges.length, 0); }); }); + +function a2aTask(over: Partial): A2ATask { + return { + id: "a1", + skill: "smart-routing", + state: "working", + input: { skill: "smart-routing", messages: [{ role: "user", content: "route this well" }] }, + artifacts: [], + events: [{ timestamp: "2026-08-30T10:00:00Z", state: "submitted" }], + metadata: {}, + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-30T10:01:00Z", + expiresAt: "2026-08-30T10:05:00Z", + ...over, + } as A2ATask; +} + +describe("fromA2A", () => { + it("maps the five A2A states", () => { + const cases: Array<[A2ATask["state"], string]> = [ + ["submitted", "queued"], + ["working", "running"], + ["completed", "succeeded"], + ["failed", "failed"], + ["cancelled", "cancelled"], + ]; + for (const [input, expected] of cases) { + const { nodes } = fromA2A([a2aTask({ state: input })]); + assert.equal(nodes.find((n) => n.kind === "work")?.state, expected, input); + } + }); + it("work node id is a2a:, label is the skill", () => { + const { nodes } = fromA2A([a2aTask({})]); + const w = nodes.find((n) => n.kind === "work"); + assert.equal(w?.id, "a2a:a1"); + assert.equal(w?.label, "smart-routing"); + }); + it("empty input emits nothing", () => { + assert.equal(fromA2A([]).nodes.length, 0); + }); +});