Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/core/graphs/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ export function layoutGraph(definition: GraphDefinition): GraphDefinition {
const depth = depths.get(node.nodeKey) ?? index,
row = rowCounts.get(depth) ?? 0;
rowCounts.set(depth, row + 1);
return [node.nodeKey, { x: depth * 300, y: row * 160 }];
return [node.nodeKey, { x: depth * 340, y: row * 180 }];
}),
);
return { ...definition, layout: { nodes } };
Expand Down
13 changes: 13 additions & 0 deletions packages/core/graphs/draft-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ function stored(storage: StorageAdapter, draftKey: string) {
}

describe("Graph draft conversation binding", () => {
it("moves a newly chosen executor to the head of the scope", () => {
const store = createGraphDraftStore(memoryStorage());
store.getState().open(key, definition, 1);
store.getState().bindAi(key, ai);
store
.getState()
.updateAi(key, { executorId: "agent-2", approverId: "member-2" });
expect(store.getState().documents[key]?.ai).toMatchObject({
executorId: "agent-2",
scopeIds: ["agent-2", "agent-1"],
approverId: "member-2",
});
});
it("returns to the same conversation after a reload, with the reply it merged", () => {
const storage = memoryStorage(),
first = createGraphDraftStore(storage);
Expand Down
1 change: 1 addition & 0 deletions packages/core/graphs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./mutations";
export * from "./draft-store";
export * from "./input-schema";
export * from "./authoring-protocol";
export * from "./studio";
5 changes: 5 additions & 0 deletions packages/core/graphs/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,13 +511,18 @@ export const GraphRunSchema = z
approval: GraphApprovalSchema.nullish(),
block_reason: reason,
archived_at: reason,
root_snapshot: z
.object({ title: z.string().catch("") })
.nullish()
.catch(null),
created_at: timestamp,
updated_at: timestamp,
})
.transform((value) => ({
id: value.id,
workspaceId: value.workspace_id,
rootIssueId: value.root_issue_id,
rootTitle: value.root_snapshot?.title || undefined,
versionId: value.version_id,
initiatorId: value.initiator_id,
state: value.state,
Expand Down
246 changes: 246 additions & 0 deletions packages/core/graphs/studio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { describe, expect, it } from "vitest";
import {
graphArtifactProducer,
graphChangeMarks,
graphEventNode,
graphRunDurationMs,
graphRunProgress,
groupLatestRuns,
insertGraphNodeOnEdge,
nextGraphNodeKey,
} from "./studio";
import type { GraphDefinition, GraphNodeRun, GraphRun } from "./types";

const base: GraphDefinition = {
schemaVersion: 2,
name: "Flow",
inputSchema: { type: "object" },
nodes: [
{ nodeKey: "start", type: "start" },
{ nodeKey: "check", type: "branch" },
{ nodeKey: "write", type: "agent" },
{ nodeKey: "end", type: "end" },
],
edges: [
{ from: "start", to: "check" },
{ from: "check", to: "write", port: "true" },
{ from: "check", to: "end", port: "false" },
{ from: "write", to: "end" },
],
layout: {
nodes: {
start: { x: 0, y: 0 },
check: { x: 300, y: 0 },
write: { x: 600, y: 0 },
end: { x: 900, y: 0 },
},
},
};

describe("insertGraphNodeOnEdge", () => {
it("splits a plain connection and places the step between its ends", () => {
const next = insertGraphNodeOnEdge(
base,
{ from: "start", to: "check" },
{ nodeKey: "agent_1", type: "agent" },
);
expect(next.edges.slice(0, 2)).toEqual([
{ from: "start", to: "agent_1" },
{ from: "agent_1", to: "check" },
]);
expect(next.edges).toHaveLength(5);
expect(next.layout?.nodes.agent_1).toEqual({ x: 150, y: 80 });
});

it("keeps a branch outcome on the incoming side", () => {
const next = insertGraphNodeOnEdge(
base,
{ from: "check", to: "write", port: "true" },
{ nodeKey: "review", type: "approval" },
);
expect(next.edges).toContainEqual({
from: "check",
to: "review",
port: "true",
});
expect(next.edges).toContainEqual({ from: "review", to: "write" });
});

it("continues an inserted branch on its true outcome", () => {
const next = insertGraphNodeOnEdge(
base,
{ from: "write", to: "end" },
{ nodeKey: "branch_2", type: "branch" },
);
expect(next.edges).toContainEqual({
from: "branch_2",
to: "end",
port: "true",
});
});

it("refuses missing connections, duplicate keys and structural steps", () => {
expect(() =>
insertGraphNodeOnEdge(
base,
{ from: "start", to: "end" },
{ nodeKey: "x", type: "agent" },
),
).toThrow();
expect(() =>
insertGraphNodeOnEdge(
base,
{ from: "start", to: "check" },
{ nodeKey: "write", type: "agent" },
),
).toThrow();
expect(() =>
insertGraphNodeOnEdge(
base,
{ from: "start", to: "check" },
{ nodeKey: "join_1", type: "join" },
),
).toThrow();
});
});

describe("nextGraphNodeKey", () => {
it("skips keys already taken", () => {
const definition = {
...base,
nodes: [...base.nodes, { nodeKey: "agent_2", type: "start" }],
};
expect(nextGraphNodeKey(definition, "agent")).toEqual({
key: "agent_3",
index: 3,
});
});
});

function nodeRun(
nodeKey: string,
nodeType: string,
state: string,
scopeId = "root",
): GraphNodeRun {
return {
id: nodeKey,
nodeKey,
nodeType,
state,
scopeId,
input: {},
inputHash: "",
maxAttempts: 1,
grantedAttempts: 0,
canRetry: false,
canResolveBlock: false,
attempts: [],
submissions: [],
};
}

describe("graphRunProgress", () => {
it("counts root working steps only", () => {
expect(
graphRunProgress({
scopes: [{ id: "root" }, { id: "loop", parent_node_run_id: "x" }],
nodes: [
nodeRun("start", "start", "succeeded"),
nodeRun("a", "agent", "succeeded"),
nodeRun("b", "branch", "skipped"),
nodeRun("c", "agent", "failed"),
nodeRun("d", "approval", "waiting_approval"),
nodeRun("inner", "agent", "succeeded", "loop"),
nodeRun("end", "end", "pending"),
],
}),
).toEqual({ done: 2, total: 4, failed: 1 });
});
});

describe("groupLatestRuns", () => {
it("groups newest first and caps each graph", () => {
const run = (id: string, graphId: string | undefined, createdAt: string) =>
({ id, graphId, createdAt }) as GraphRun;
const grouped = groupLatestRuns(
[
run("1", "g1", "2026-01-01T00:00:00Z"),
run("2", "g1", "2026-01-03T00:00:00Z"),
run("3", "g1", "2026-01-02T00:00:00Z"),
run("4", "g2", "2026-01-01T00:00:00Z"),
run("5", undefined, "2026-01-05T00:00:00Z"),
],
2,
);
expect(grouped.get("g1")?.map((item) => item.id)).toEqual(["2", "3"]);
expect(grouped.get("g2")?.map((item) => item.id)).toEqual(["4"]);
expect(grouped.size).toBe(2);
});
});

describe("graphRunDurationMs", () => {
it("measures to the finish, the last update, or now", () => {
const createdAt = "2026-01-01T00:00:00Z";
expect(
graphRunDurationMs({
state: "succeeded",
createdAt,
updatedAt: "2026-01-01T00:09:00Z",
finishedAt: "2026-01-01T00:05:00Z",
}),
).toBe(300_000);
expect(
graphRunDurationMs({
state: "failed",
createdAt,
updatedAt: "2026-01-01T00:02:00Z",
}),
).toBe(120_000);
expect(
graphRunDurationMs(
{ state: "active", createdAt, updatedAt: createdAt },
Date.parse("2026-01-01T00:01:00Z"),
),
).toBe(60_000);
expect(
graphRunDurationMs({ state: "active", createdAt: "", updatedAt: "" }),
).toBeNull();
});
});

describe("graphEventNode", () => {
const research = {
...nodeRun("research", "agent", "succeeded"),
id: "node-run-1",
attempts: [
{
taskId: "task-1",
attemptNo: 1,
generation: 0,
state: "succeeded",
manifest: {},
},
],
};
const run = { nodes: [research, nodeRun("review", "approval", "pending")] };
it("resolves task ids, scoped keys and node run ids", () => {
expect(graphEventNode(run, "task-1")?.nodeKey).toBe("research");
expect(graphEventNode(run, "root/review")?.nodeKey).toBe("review");
expect(graphEventNode(run, "node-run-1")?.nodeKey).toBe("research");
expect(graphArtifactProducer(run, "task-1")?.nodeKey).toBe("research");
});
it("ignores messages that are not references", () => {
expect(graphEventNode(run, "user_cancelled")).toBeUndefined();
expect(graphEventNode(run, undefined)).toBeUndefined();
});
});

describe("graphChangeMarks", () => {
it("lets an added mark win over a changed one", () => {
expect(graphChangeMarks(["a"], ["a", "b"])).toEqual({
a: "added",
b: "changed",
});
});
});
Loading
Loading