import { describe, it, expect } from "vitest"; import { Type } from "@alkdev/typebox"; import { FlowGraph, buildTypeEdges } from "../../src/graph/construction.js"; import type { OperationSpec, CallEventMapValue } from "../../src/graph/construction.js"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, InvalidTransitionError, } from "../../src/error/index.js"; import type { CallStatus } from "../../src/schema/enums.js"; describe("FlowGraph constructor", () => { it("creates an empty graph", () => { const fg = new FlowGraph(); expect(fg.order).toBe(0); expect(fg.size).toBe(0); expect(fg.nodes()).toEqual([]); expect(fg.edges()).toEqual([]); }); it("exposes the underlying graphology instance via graph getter", () => { const fg = new FlowGraph(); expect(fg.graph).toBeDefined(); expect(fg.graph.order).toBe(0); }); it("accepts FlowGraphOptions", () => { const fg = new FlowGraph({ type: "directed", multi: false, allowSelfLoops: false }); expect(fg.order).toBe(0); }); }); describe("FlowGraph._edgeKey", () => { it("produces deterministic keys", () => { const fg = new FlowGraph(); expect(fg._edgeKey("a", "b")).toBe("a->b"); expect(fg._edgeKey("task.classify", "task.enrich")).toBe("task.classify->task.enrich"); }); }); describe("FlowGraph node operations", () => { it("addNode adds a node with attributes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); expect(fg.hasNode("a")).toBe(true); expect(fg.getNodeAttributes("a")).toEqual({ name: "a" }); expect(fg.order).toBe(1); }); it("addNode throws DuplicateNodeError on duplicate", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); expect(() => fg.addNode("a", { name: "a2" })).toThrow(DuplicateNodeError); expect(() => fg.addNode("a", { name: "a2" })).toThrow('Node with key "a" already exists'); }); it("removeNode removes a node and its edges", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); fg.removeNode("a"); expect(fg.hasNode("a")).toBe(false); expect(fg.hasEdge("a", "b")).toBe(false); expect(fg.size).toBe(0); }); it("removeNode throws NodeNotFoundError if missing", () => { const fg = new FlowGraph(); expect(() => fg.removeNode("missing")).toThrow(NodeNotFoundError); }); it("updateNode partially merges attributes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a", version: "1.0" }); fg.updateNode("a", { version: "2.0" }); expect(fg.getNodeAttributes("a")).toEqual({ name: "a", version: "2.0" }); }); it("updateNode throws NodeNotFoundError if missing", () => { const fg = new FlowGraph(); expect(() => fg.updateNode("missing", { x: 1 })).toThrow(NodeNotFoundError); }); it("hasNode returns correct boolean", () => { const fg = new FlowGraph(); expect(fg.hasNode("a")).toBe(false); fg.addNode("a", { name: "a" }); expect(fg.hasNode("a")).toBe(true); }); it("getNodeAttributes returns attributes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a", x: 42 }); expect(fg.getNodeAttributes("a")).toEqual({ name: "a", x: 42 }); }); it("getNodeAttributes throws NodeNotFoundError if missing", () => { const fg = new FlowGraph(); expect(() => fg.getNodeAttributes("missing")).toThrow(NodeNotFoundError); }); }); describe("FlowGraph edge operations", () => { it("addEdge adds a directed edge with attributes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b", { edgeType: "typed" }); expect(fg.hasEdge("a", "b")).toBe(true); expect(fg.size).toBe(1); }); it("addEdge creates deterministic edge key", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); expect(fg.edges()).toEqual(["a->b"]); }); it("addEdge throws NodeNotFoundError for missing source", () => { const fg = new FlowGraph(); fg.addNode("b", { name: "b" }); expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError); }); it("addEdge throws NodeNotFoundError for missing target", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); expect(() => fg.addEdge("a", "b")).toThrow(NodeNotFoundError); }); it("addEdge throws DuplicateEdgeError if edge exists", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); expect(() => fg.addEdge("a", "b")).toThrow(DuplicateEdgeError); }); it("addEdge throws CycleError if edge creates cycle", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addEdge("a", "b"); fg.addEdge("b", "c"); expect(() => fg.addEdge("c", "a")).toThrow(CycleError); }); it("CycleError includes cycle path", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); try { fg.addEdge("b", "a"); expect.unreachable("should throw"); } catch (e) { expect(e).toBeInstanceOf(CycleError); const ce = e as CycleError; expect(ce.cycles.length).toBeGreaterThan(0); expect(ce.cycles[0]![0]).toBe("b"); expect(ce.cycles[0]!.at(-1)).toBe("b"); } }); it("addEdge allows non-cycle edges in DAG", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addEdge("a", "b"); fg.addEdge("a", "c"); fg.addEdge("b", "c"); expect(fg.size).toBe(3); }); it("removeEdge removes an existing edge", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); fg.removeEdge("a", "b"); expect(fg.hasEdge("a", "b")).toBe(false); expect(fg.size).toBe(0); }); it("removeEdge is a no-op if edge doesn't exist", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(() => fg.removeEdge("a", "b")).not.toThrow(); }); it("hasEdge returns correct boolean", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(fg.hasEdge("a", "b")).toBe(false); fg.addEdge("a", "b"); expect(fg.hasEdge("a", "b")).toBe(true); }); it("getEdgeAttributes returns edge attributes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b", { edgeType: "typed", compatible: true }); const attrs = fg.getEdgeAttributes("a", "b"); expect(attrs.edgeType).toBe("typed"); expect(attrs.compatible).toBe(true); }); it("getEdgeAttributes throws if edge doesn't exist", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(() => fg.getEdgeAttributes("a", "b")).toThrow(); }); }); describe("FlowGraph query methods", () => { it("nodes returns all node keys", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(fg.nodes()).toEqual(["a", "b"]); }); it("edges returns all edge keys", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); expect(fg.edges()).toEqual(["a->b"]); }); it("order returns node count", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); expect(fg.order).toBe(2); }); it("size returns edge count", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); expect(fg.size).toBe(1); }); it("forEachNode iterates over all nodes", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); const collected: Array<{ key: string; attrs: Record }> = []; fg.forEachNode((key, attrs) => collected.push({ key, attrs })); expect(collected.length).toBe(2); expect(collected[0]!.key).toBe("a"); expect(collected[1]!.key).toBe("b"); }); it("forEachEdge iterates over all edges", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b", { edgeType: "typed" }); const collected: Array<{ key: string; source: string; target: string }> = []; fg.forEachEdge((key, _attrs, source, target) => collected.push({ key, source, target }), ); expect(collected.length).toBe(1); expect(collected[0]!.key).toBe("a->b"); expect(collected[0]!.source).toBe("a"); expect(collected[0]!.target).toBe("b"); }); it("predecessors returns in-neighbors", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addEdge("a", "c"); fg.addEdge("b", "c"); expect(fg.predecessors("c")).toEqual(["a", "b"]); }); it("successors returns out-neighbors", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addEdge("a", "b"); fg.addEdge("a", "c"); expect(fg.successors("a")).toEqual(["b", "c"]); }); }); describe("FlowGraph static stubs", () => { it("fromCallEvents returns empty graph for empty events", () => { const graph = FlowGraph.fromCallEvents([]); expect(graph.order).toBe(0); expect(graph.size).toBe(0); }); it("fromJSON throws not implemented", () => { expect(() => FlowGraph.fromJSON({} as never)).toThrow(); }); }); describe("FlowGraph.addOperation", () => { it("adds an operation node with namespace.name key", () => { const fg = new FlowGraph(); const spec: OperationSpec = { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), }; fg.addOperation(spec); expect(fg.hasNode("task.classify")).toBe(true); const attrs = fg.getNodeAttributes("task.classify") as Record; expect(attrs.name).toBe("classify"); expect(attrs.namespace).toBe("task"); expect(attrs.version).toBe("1.0.0"); expect(attrs.type).toBe("query"); }); it("throws DuplicateNodeError for duplicate operation", () => { const fg = new FlowGraph(); const spec: OperationSpec = { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), }; fg.addOperation(spec); expect(() => fg.addOperation(spec)).toThrow(DuplicateNodeError); }); it("includes optional description and tags", () => { const fg = new FlowGraph(); const spec: OperationSpec = { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), description: "Classifies text", tags: ["nlp", "ml"], }; fg.addOperation(spec); const attrs = fg.getNodeAttributes("task.classify") as Record; expect(attrs.description).toBe("Classifies text"); expect(attrs.tags).toEqual(["nlp", "ml"]); }); }); describe("FlowGraph.addTypedEdge", () => { it("adds a typed edge with edgeType: 'typed'", () => { const fg = new FlowGraph(); const spec1: OperationSpec = { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), }; const spec2: OperationSpec = { name: "enrich", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ label: Type.String() }), outputSchema: Type.Object({ enriched: Type.String() }), }; fg.addOperation(spec1); fg.addOperation(spec2); fg.addTypedEdge("task.classify", "task.enrich", { compatible: true }); expect(fg.hasEdge("task.classify", "task.enrich")).toBe(true); const edgeAttrs = fg.getEdgeAttributes("task.classify", "task.enrich") as Record; expect(edgeAttrs.edgeType).toBe("typed"); expect(edgeAttrs.compatible).toBe(true); }); it("throws NodeNotFoundError if source doesn't exist", () => { const fg = new FlowGraph(); const spec: OperationSpec = { name: "enrich", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ label: Type.String() }), outputSchema: Type.Object({ enriched: Type.String() }), }; fg.addOperation(spec); expect(() => fg.addTypedEdge("task.missing", "task.enrich", { compatible: true })).toThrow(NodeNotFoundError); }); it("throws NodeNotFoundError if target doesn't exist", () => { const fg = new FlowGraph(); const spec: OperationSpec = { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String() }), }; fg.addOperation(spec); expect(() => fg.addTypedEdge("task.classify", "task.missing", { compatible: true })).toThrow(NodeNotFoundError); }); it("includes detail and mismatches when provided", () => { const fg = new FlowGraph(); const spec1: OperationSpec = { name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ y: Type.Number() }), }; const spec2: OperationSpec = { name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ z: Type.Boolean() }), }; fg.addOperation(spec1); fg.addOperation(spec2); fg.addTypedEdge("op.a", "op.b", { compatible: false, detail: "output mismatch", mismatches: [{ path: "/y", expected: "string", actual: "number" }], }); const edgeAttrs = fg.getEdgeAttributes("op.a", "op.b") as Record; expect(edgeAttrs.compatible).toBe(false); expect(edgeAttrs.detail).toBe("output mismatch"); expect(edgeAttrs.mismatches).toEqual([{ path: "/y", expected: "string", actual: "number" }]); }); it("throws CycleError if edge would create cycle", () => { const fg = new FlowGraph(); fg.addOperation({ name: "a", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ y: Type.String() }), }); fg.addOperation({ name: "b", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ y: Type.String() }), outputSchema: Type.Object({ x: Type.String() }), }); fg.addTypedEdge("op.a", "op.b", { compatible: true }); expect(() => fg.addTypedEdge("op.b", "op.a", { compatible: true })).toThrow(CycleError); }); }); describe("FlowGraph.fromSpecs", () => { it("creates operation nodes for each spec", () => { const specs: OperationSpec[] = [ { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.order).toBe(2); expect(graph.hasNode("task.extract")).toBe(true); expect(graph.hasNode("task.classify")).toBe(true); }); it("adds type-compatibility edges via buildTypeEdges", () => { const specs: OperationSpec[] = [ { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.size).toBeGreaterThan(0); }); it("adds compatible edge when output matches input", () => { const specs: OperationSpec[] = [ { name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }) }, { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.hasEdge("task.extract", "task.classify")).toBe(true); const attrs = graph.getEdgeAttributes("task.extract", "task.classify") as Record; expect(attrs.compatible).toBe(true); }); it("adds incompatible edge when output does not match input", () => { const specs: OperationSpec[] = [ { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }) }, { name: "count", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ count: Type.Number() }), outputSchema: Type.Object({ result: Type.Number() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.hasEdge("task.classify", "task.count")).toBe(true); const attrs = graph.getEdgeAttributes("task.classify", "task.count") as Record; expect(attrs.compatible).toBe(false); }); it("skips edges when target inputSchema is Unknown", () => { const specs: OperationSpec[] = [ { name: "source", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Object({ result: Type.String() }) }, { name: "unk_input", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Unknown(), outputSchema: Type.Object({ y: Type.String() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.hasEdge("op.source", "op.unk_input")).toBe(false); }); it("skips edges when source outputSchema is Unknown", () => { const specs: OperationSpec[] = [ { name: "unk_output", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ x: Type.String() }), outputSchema: Type.Unknown() }, { name: "target", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ result: Type.String() }), outputSchema: Type.Object({ z: Type.String() }) }, ]; const graph = FlowGraph.fromSpecs(specs); expect(graph.hasEdge("op.unk_output", "op.target")).toBe(false); }); it("throws DuplicateNodeError for duplicate specs", () => { const stringOutput = Type.Object({ label: Type.String() }); const specs: OperationSpec[] = [ { name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput }, { name: "classify", namespace: "task", version: "2.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: stringOutput }, ]; expect(() => FlowGraph.fromSpecs(specs)).toThrow(DuplicateNodeError); }); it("returns empty graph for empty specs", () => { const graph = FlowGraph.fromSpecs([]); expect(graph.order).toBe(0); expect(graph.size).toBe(0); }); }); describe("buildTypeEdges (standalone)", () => { it("adds edges to an incrementally constructed graph", () => { const fg = new FlowGraph(); fg.addOperation({ name: "extract", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }), }); fg.addOperation({ name: "classify", namespace: "task", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }), }); expect(fg.size).toBe(0); buildTypeEdges(fg); expect(fg.size).toBeGreaterThan(0); expect(fg.hasEdge("task.extract", "task.classify")).toBe(true); }); it("is callable after adding more operations", () => { const fg = new FlowGraph(); fg.addOperation({ name: "extract", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ raw: Type.String() }), outputSchema: Type.Object({ text: Type.String() }), }); buildTypeEdges(fg); expect(fg.size).toBe(0); fg.addOperation({ name: "classify", namespace: "op", version: "1.0.0", type: "query", inputSchema: Type.Object({ text: Type.String() }), outputSchema: Type.Object({ label: Type.String(), score: Type.Number() }), }); buildTypeEdges(fg); expect(fg.hasEdge("op.extract", "op.classify")).toBe(true); }); }); describe("FlowGraph re-exports", () => { it("is re-exported from src/graph/index.ts", async () => { const mod = await import("../../src/graph/index.js"); expect(mod.FlowGraph).toBeDefined(); expect(mod.buildTypeEdges).toBeDefined(); }); it("is re-exported from src/index.ts", async () => { const mod = await import("../../src/index.js"); expect(mod.FlowGraph).toBeDefined(); }); }); describe("FlowGraph cycle detection", () => { it("prevents simple two-node cycle", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addEdge("a", "b"); expect(() => fg.addEdge("b", "a")).toThrow(CycleError); }); it("prevents longer cycle", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addNode("d", { name: "d" }); fg.addEdge("a", "b"); fg.addEdge("b", "c"); fg.addEdge("c", "d"); expect(() => fg.addEdge("d", "a")).toThrow(CycleError); }); it("allows diamond DAG", () => { const fg = new FlowGraph(); fg.addNode("top", { name: "top" }); fg.addNode("left", { name: "left" }); fg.addNode("right", { name: "right" }); fg.addNode("bottom", { name: "bottom" }); fg.addEdge("top", "left"); fg.addEdge("top", "right"); fg.addEdge("left", "bottom"); fg.addEdge("right", "bottom"); expect(fg.size).toBe(4); expect(() => fg.addEdge("bottom", "top")).toThrow(CycleError); }); it("does not create false positive cycle detection", () => { const fg = new FlowGraph(); fg.addNode("a", { name: "a" }); fg.addNode("b", { name: "b" }); fg.addNode("c", { name: "c" }); fg.addEdge("a", "b"); fg.addEdge("a", "c"); expect(() => fg.addEdge("b", "c")).not.toThrow(); }); }); describe("FlowGraph.fromCallEvents", () => { const requestedEvent: CallEventMapValue = { type: "call.requested", requestId: "req-1", operationId: "task.classify", input: { text: "hello" }, timestamp: "2026-01-01T00:00:00Z", }; const requestedWithParent: CallEventMapValue = { type: "call.requested", requestId: "req-2", operationId: "task.enrich", input: { label: "greeting" }, timestamp: "2026-01-01T00:00:01Z", parentRequestId: "req-1", }; const respondedEvent: CallEventMapValue = { type: "call.responded", requestId: "req-1", output: { label: "greeting" }, timestamp: "2026-01-01T00:00:02Z", }; const errorEvent: CallEventMapValue = { type: "call.error", requestId: "req-1", error: { code: "INTERNAL", message: "Something went wrong" }, timestamp: "2026-01-01T00:00:03Z", }; const abortedEvent: CallEventMapValue = { type: "call.aborted", requestId: "req-1", timestamp: "2026-01-01T00:00:04Z", }; const completedEvent: CallEventMapValue = { type: "call.completed", requestId: "req-1", output: { label: "greeting" }, timestamp: "2026-01-01T00:00:05Z", }; it("adds node from call.requested event", () => { const graph = FlowGraph.fromCallEvents([requestedEvent]); expect(graph.order).toBe(1); expect(graph.hasNode("req-1")).toBe(true); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("pending"); expect(attrs.operationId).toBe("task.classify"); }); it("creates triggered edge from parentRequestId", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, requestedWithParent]); expect(graph.order).toBe(2); expect(graph.hasNode("req-2")).toBe(true); expect(graph.hasEdge("req-1", "req-2")).toBe(true); const edgeAttrs = graph.getEdgeAttributes("req-1", "req-2") as Record; expect(edgeAttrs.edgeType).toBe("triggered"); }); it("updates status to completed on call.responded", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, respondedEvent]); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); expect(attrs.output).toEqual({ label: "greeting" }); expect(attrs.completedAt).toBe("2026-01-01T00:00:02Z"); }); it("updates status to failed on call.error", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, errorEvent]); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("failed"); expect(attrs.error).toEqual({ code: "INTERNAL", message: "Something went wrong" }); expect(attrs.completedAt).toBe("2026-01-01T00:00:03Z"); }); it("updates status to aborted on call.aborted", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, abortedEvent]); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("aborted"); expect(attrs.completedAt).toBe("2026-01-01T00:00:04Z"); }); it("updates status to completed on call.completed", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, completedEvent]); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); expect(attrs.completedAt).toBe("2026-01-01T00:00:05Z"); }); it("is idempotent — duplicate events have no effect", () => { const graph = FlowGraph.fromCallEvents([requestedEvent, requestedEvent, respondedEvent, respondedEvent]); expect(graph.order).toBe(1); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); }); it("ignores responded/error/aborted for unknown requestId", () => { const graph = FlowGraph.fromCallEvents([respondedEvent, errorEvent, abortedEvent]); expect(graph.order).toBe(0); }); it("creates node for unknown operationId", () => { const unknownOpEvent: CallEventMapValue = { type: "call.requested", requestId: "req-unknown", operationId: "unknown.op", input: {}, timestamp: "2026-01-01T00:00:00Z", }; const graph = FlowGraph.fromCallEvents([unknownOpEvent]); expect(graph.order).toBe(1); const attrs = graph.getNodeAttributes("req-unknown") as Record; expect(attrs.status).toBe("pending"); expect(attrs.operationId).toBe("unknown.op"); }); it("processes full event sequence", () => { const req1: CallEventMapValue = { type: "call.requested", requestId: "req-parent", operationId: "task.parent", input: {}, timestamp: "2026-01-01T00:00:00Z", }; const req2: CallEventMapValue = { type: "call.requested", requestId: "req-child", operationId: "task.child", input: {}, timestamp: "2026-01-01T00:00:01Z", parentRequestId: "req-parent", }; const resp: CallEventMapValue = { type: "call.responded", requestId: "req-parent", output: "done", timestamp: "2026-01-01T00:00:02Z", }; const graph = FlowGraph.fromCallEvents([req1, req2, resp]); expect(graph.order).toBe(2); expect(graph.hasEdge("req-parent", "req-child")).toBe(true); const parentAttrs = graph.getNodeAttributes("req-parent") as Record; expect(parentAttrs.status).toBe("completed"); const childAttrs = graph.getNodeAttributes("req-child") as Record; expect(childAttrs.status).toBe("pending"); }); it("stores identity and startedAt from call.requested", () => { const event: CallEventMapValue = { type: "call.requested", requestId: "req-id", operationId: "task.op", input: {}, timestamp: "2026-01-01T00:00:00Z", identity: { id: "user-1", scopes: ["read"] }, startedAt: "2026-01-01T00:00:01Z", }; const graph = FlowGraph.fromCallEvents([event]); const attrs = graph.getNodeAttributes("req-id") as Record; expect(attrs.identity).toEqual({ id: "user-1", scopes: ["read"] }); expect(attrs.startedAt).toBe("2026-01-01T00:00:01Z"); }); it("skips triggered edge if parent node does not exist", () => { const orphanEvent: CallEventMapValue = { type: "call.requested", requestId: "req-orphan", operationId: "task.child", input: {}, timestamp: "2026-01-01T00:00:00Z", parentRequestId: "req-nonexistent", }; const graph = FlowGraph.fromCallEvents([orphanEvent]); expect(graph.order).toBe(1); expect(graph.hasNode("req-orphan")).toBe(true); expect(graph.size).toBe(0); }); }); describe("FlowGraph.updateFromEvent", () => { it("processes single event for real-time pattern", () => { const graph = new FlowGraph(); graph.updateFromEvent({ type: "call.requested", requestId: "req-1", operationId: "task.classify", input: { text: "hello" }, timestamp: "2026-01-01T00:00:00Z", }); expect(graph.hasNode("req-1")).toBe(true); graph.updateFromEvent({ type: "call.responded", requestId: "req-1", output: { label: "hi" }, timestamp: "2026-01-01T00:00:02Z", }); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); expect(attrs.output).toEqual({ label: "hi" }); }); it("ignores events for unknown requestId", () => { const graph = new FlowGraph(); graph.updateFromEvent({ type: "call.responded", requestId: "unknown", output: "x", timestamp: "2026-01-01T00:00:00Z", }); expect(graph.order).toBe(0); }); it("ignores terminal event re-processing", () => { const graph = new FlowGraph(); graph.updateFromEvent({ type: "call.requested", requestId: "req-1", operationId: "task.op", input: {}, timestamp: "2026-01-01T00:00:00Z", }); graph.updateFromEvent({ type: "call.responded", requestId: "req-1", output: "done", timestamp: "2026-01-01T00:00:01Z", }); graph.updateFromEvent({ type: "call.error", requestId: "req-1", error: { code: "X", message: "Y" }, timestamp: "2026-01-01T00:00:02Z", }); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); }); }); describe("FlowGraph.addCall", () => { it("adds a call node", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.classify", status: "pending", input: { text: "hello" }, }); expect(graph.hasNode("req-1")).toBe(true); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("pending"); }); it("adds triggered edge when parentRequestId is present", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-parent", operationId: "task.parent", status: "pending", input: {}, }); graph.addCall({ requestId: "req-child", operationId: "task.child", status: "pending", input: {}, parentRequestId: "req-parent", }); expect(graph.hasEdge("req-parent", "req-child")).toBe(true); const edgeAttrs = graph.getEdgeAttributes("req-parent", "req-child") as Record; expect(edgeAttrs.edgeType).toBe("triggered"); }); it("is idempotent — duplicate addCall is ignored", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {}, }); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {}, }); expect(graph.order).toBe(1); }); it("does not throw if parentRequestId node does not exist", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-child", operationId: "task.child", status: "pending", input: {}, parentRequestId: "nonexistent", }); expect(graph.hasNode("req-child")).toBe(true); expect(graph.size).toBe(0); }); }); describe("FlowGraph.addDependency", () => { it("creates depends_on edge", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); graph.addDependency("req-1", "req-2"); const edgeKey = "req-1->req-2:depends_on"; expect(graph.graph.hasEdge(edgeKey)).toBe(true); const attrs = graph.graph.getEdgeAttributes(edgeKey) as Record; expect(attrs.edgeType).toBe("depends_on"); }); it("is idempotent — duplicate addDependency is ignored", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); graph.addDependency("req-1", "req-2"); graph.addDependency("req-1", "req-2"); expect(graph.graph.hasEdge("req-1->req-2:depends_on")).toBe(true); }); it("throws NodeNotFoundError if source doesn't exist", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); expect(() => graph.addDependency("missing", "req-2")).toThrow(NodeNotFoundError); }); it("throws NodeNotFoundError if target doesn't exist", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); expect(() => graph.addDependency("req-1", "missing")).toThrow(NodeNotFoundError); }); it("throws CycleError if adding would create cycle", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); graph.addCall({ requestId: "req-3", operationId: "task.c", status: "pending", input: {} }); graph.addEdge("req-1", "req-2"); graph.addEdge("req-2", "req-3"); expect(() => graph.addDependency("req-3", "req-1")).toThrow(CycleError); }); }); describe("FlowGraph.updateStatus", () => { it("transitions pending to running", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("running"); }); it("transitions running to completed", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); expect(attrs.completedAt).toBe("2026-01-01T00:00:01Z"); }); it("transitions running to failed", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "failed"); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("failed"); }); it("transitions pending to aborted", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "aborted"); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("aborted"); }); it("transitions running to aborted", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "aborted"); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("aborted"); }); it("is no-op if status is already the target", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "pending"); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("pending"); }); it("throws InvalidTransitionError for completed to running", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); }); it("throws InvalidTransitionError for failed to running", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "failed"); expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); }); it("throws InvalidTransitionError for aborted to running", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "aborted"); expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); }); it("throws InvalidTransitionError for pending to completed", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); expect(() => graph.updateStatus("req-1", "completed")).toThrow(InvalidTransitionError); }); it("throws InvalidTransitionError for pending to failed", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); expect(() => graph.updateStatus("req-1", "failed")).toThrow(InvalidTransitionError); }); it("throws NodeNotFoundError for unknown requestId", () => { const graph = new FlowGraph(); expect(() => graph.updateStatus("missing", "running")).toThrow(NodeNotFoundError); }); it("InvalidTransitionError contains from/to info", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); try { graph.updateStatus("req-1", "running"); expect.unreachable("should throw"); } catch (e) { expect(e).toBeInstanceOf(InvalidTransitionError); const ite = e as InvalidTransitionError; expect(ite.requestId).toBe("req-1"); expect(ite.from).toBe("completed" as CallStatus); expect(ite.to).toBe("running" as CallStatus); } }); it("merges extra attributes on transition", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); graph.updateStatus("req-1", "running"); graph.updateStatus("req-1", "completed", { output: { result: 42 }, completedAt: "2026-01-01T00:00:01Z", }); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.status).toBe("completed"); expect(attrs.output).toEqual({ result: 42 }); expect(attrs.completedAt).toBe("2026-01-01T00:00:01Z"); }); }); describe("FlowGraph.updateCall", () => { it("partially merges call attributes", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {}, }); graph.updateCall("req-1", { output: "some result" }); const attrs = graph.getNodeAttributes("req-1") as Record; expect(attrs.output).toBe("some result"); expect(attrs.status).toBe("pending"); }); it("throws NodeNotFoundError for unknown requestId", () => { const graph = new FlowGraph(); expect(() => graph.updateCall("missing", { output: "x" })).toThrow(NodeNotFoundError); }); }); describe("FlowGraph.removeCall", () => { it("removes node and attached edges", () => { const graph = new FlowGraph(); graph.addCall({ requestId: "req-1", operationId: "task.parent", status: "pending", input: {} }); graph.addCall({ requestId: "req-2", operationId: "task.child", status: "pending", input: {}, parentRequestId: "req-1" }); expect(graph.size).toBe(1); graph.removeCall("req-2"); expect(graph.hasNode("req-2")).toBe(false); expect(graph.size).toBe(0); expect(graph.hasNode("req-1")).toBe(true); }); it("is a no-op if requestId doesn't exist", () => { const graph = new FlowGraph(); expect(() => graph.removeCall("missing")).not.toThrow(); }); });