Files
rohitg00__agentmemory/test/graph.test.ts
Rohit Ghumare 436c86c86d fix(viewer): graph tab blank on large graphs (graph/query pagination)
Closes #753.

POST /agentmemory/graph/query with an unbounded body ({}) on a corpus
with 11k+ nodes returned HTTP 500 'Invocation stopped' because the
response payload exceeded the iii state response channel ceiling.
Viewer's api() returns null on non-2xx so the failure surfaced as
'0 nodes / 0 edges' on a blank canvas with no signal — same shape as
#544, never covered for graph/query.

Backend (src/functions/graph.ts)

- mem::graph-query accepts limit + offset on every branch and applies
  a default cap (DEFAULT_GRAPH_QUERY_LIMIT = 500, MAX = 5000) so an
  empty {} body never materializes the whole graph in one invocation.
- Empty-body / nodeType-only branch ranks nodes by incident-edge
  degree before paginating so the truncated page surfaces the densest
  subgraph rather than an arbitrary KV scan order.
- Edges in the page response are restricted to edges with BOTH
  endpoints in the page; cross-page edges are dropped so the viewer
  doesn't render dangling lines.
- Response shape extended: totalNodes, totalEdges, truncated, limit,
  offset. totalNodes / totalEdges reflect the unbounded result for the
  given filter so callers can render 'showing X of Y' without
  re-querying.

API (src/triggers/api.ts)

- api::graph-query whitelists payload fields (startNodeId, nodeType,
  maxDepth, query, limit, offset) per AGENTS.md security rule. No
  more raw req.body spread.

Viewer (src/viewer/index.html)

- Initial /graph/query sends an explicit limit (500) rather than {}.
- Distinguishes apiPost === null (server error) from
  { nodes: [], edges: [] } (truly empty corpus). Failure now renders
  a visible 'Graph query failed' banner with a Retry button instead
  of the 'No graph data yet. Building...' empty state.
- Truncation banner above the search box: 'Showing N of M nodes
  (most-connected first). The full graph is too large to render at
  once.' so operators know the page is bounded.
- state.graph gains queryError, truncated, totalNodes, totalEdges
  fields.

Tests (test/graph.test.ts)

- Unbounded {} body caps to 500 with truncated=true on a 1,200-node
  seed; high-degree nodes land on page 1.
- limit + offset paginate without overlap on a 50-node seed.
- limit clamped above MAX returns at most MAX.
- Cross-page edges excluded from page response; totalEdges still
  counts unbounded.

121 test files / 1330 tests pass.
2026-06-02 23:50:59 +01:00

382 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerGraphFunction } from "../src/functions/graph.js";
import type {
CompressedObservation,
GraphNode,
GraphEdge,
GraphQueryResult,
} from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
const mockProvider = {
name: "test",
compress: vi.fn().mockResolvedValue(`<entities>
<entity type="file" name="src/index.ts"><property key="path">src/index.ts</property></entity>
<entity type="function" name="main"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
</relationships>`),
summarize: vi.fn(),
};
const testObs: CompressedObservation = {
id: "obs_1",
sessionId: "ses_1",
timestamp: "2026-02-01T10:00:00Z",
type: "file_edit",
title: "Edit index file",
facts: ["Modified main function"],
narrative: "Updated index.ts with main function",
concepts: ["typescript", "entry-point"],
files: ["src/index.ts"],
importance: 7,
};
describe("Graph Functions", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(() => {
sdk = mockSdk();
kv = mockKV();
vi.clearAllMocks();
registerGraphFunction(sdk as never, kv as never, mockProvider as never);
});
it("graph-extract creates nodes and edges from XML response", async () => {
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.length).toBe(2);
expect(nodes.find((n) => n.name === "src/index.ts")).toBeDefined();
expect(nodes.find((n) => n.name === "main")).toBeDefined();
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges.length).toBe(1);
expect(edges[0].type).toBe("uses");
});
it("graph-extract accepts self-closing entity tags", async () => {
mockProvider.compress.mockResolvedValueOnce(`<entities>
<entity type="file" name="src/index.ts"/>
<entity type="function" name="main"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
</relationships>`);
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.some((n) => n.name === "src/index.ts")).toBe(true);
expect(nodes.some((n) => n.name === "main")).toBe(true);
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges).toHaveLength(1);
expect(edges[0].type).toBe("uses");
});
it("graph-extract tolerates reordered attributes (#635)", async () => {
// Codex CLI's LLM tends to emit attribute order name→type and
// source→target→type rather than the hard-coded type-first /
// type/source/target/weight sequence the old parser required.
mockProvider.compress.mockResolvedValueOnce(`<entities>
<entity name="src/index.ts" type="file"/>
<entity name="main" type="function"><property key="lang">typescript</property></entity>
</entities>
<relationships>
<relationship source="src/index.ts" target="main" type="uses" weight="0.9"/>
</relationships>`);
const result = (await sdk.trigger("mem::graph-extract", {
observations: [testObs],
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
expect(result.success).toBe(true);
expect(result.nodesAdded).toBe(2);
expect(result.edgesAdded).toBe(1);
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
expect(nodes.find((n) => n.name === "src/index.ts")?.type).toBe("file");
expect(nodes.find((n) => n.name === "main")?.type).toBe("function");
const edges = await kv.list<GraphEdge>("mem:graph:edges");
expect(edges).toHaveLength(1);
expect(edges[0].type).toBe("uses");
expect(edges[0].weight).toBeCloseTo(0.9, 5);
});
it("graph-query with search returns matching nodes", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const result = (await sdk.trigger("mem::graph-query", {
query: "index",
})) as GraphQueryResult;
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
expect(result.nodes.some((n) => n.name.includes("index"))).toBe(true);
});
it("graph-query with startNodeId does BFS traversal", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
const fileNode = nodes.find((n) => n.name === "src/index.ts")!;
const result = (await sdk.trigger("mem::graph-query", {
startNodeId: fileNode.id,
maxDepth: 2,
})) as GraphQueryResult;
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
expect(result.edges.length).toBeGreaterThanOrEqual(1);
expect(result.depth).toBe(2);
});
it("graph-stats returns counts by type", async () => {
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
const result = (await sdk.trigger("mem::graph-stats", {})) as {
totalNodes: number;
totalEdges: number;
nodesByType: Record<string, number>;
edgesByType: Record<string, number>;
};
expect(result.totalNodes).toBe(2);
expect(result.totalEdges).toBe(1);
expect(result.nodesByType.file).toBe(1);
expect(result.nodesByType.function).toBe(1);
expect(result.edgesByType.uses).toBe(1);
});
it("graph-extract returns error for empty observations", async () => {
const result = (await sdk.trigger("mem::graph-extract", {
observations: [],
})) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toContain("No observations");
});
// #753: an unbounded {} body used to materialize every node+edge in
// one payload, which exceeded the iii state response channel on
// large corpora (11k+ nodes) and returned HTTP 500 "Invocation
// stopped". The fix caps the page at DEFAULT_GRAPH_QUERY_LIMIT (500)
// and surfaces totalNodes / totalEdges so callers know it was
// truncated.
it("caps an unbounded graph-query body to a default page and reports totals", async () => {
// Seed a graph with more nodes than the default page size.
const NODE_COUNT = 1200;
for (let i = 0; i < NODE_COUNT; i++) {
const node: GraphNode = {
id: `n_${i.toString().padStart(4, "0")}`,
type: "concept",
name: `node-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
} as GraphNode;
await kv.set("mem:graph:nodes", node.id, node);
}
// A few edges among the first 50 nodes so high-degree ranking has
// something to grade.
for (let i = 0; i < 50; i++) {
const edge: GraphEdge = {
id: `e_${i}`,
type: "related_to",
sourceNodeId: `n_${i.toString().padStart(4, "0")}`,
targetNodeId: `n_${((i + 1) % 50).toString().padStart(4, "0")}`,
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
} as GraphEdge;
await kv.set("mem:graph:edges", edge.id, edge);
}
const unbounded = (await sdk.trigger(
"mem::graph-query",
{},
)) as GraphQueryResult;
expect(unbounded.totalNodes).toBe(NODE_COUNT);
expect(unbounded.nodes.length).toBe(500);
expect(unbounded.truncated).toBe(true);
expect(unbounded.limit).toBe(500);
expect(unbounded.offset).toBe(0);
// The 50 connected nodes should be on the first page since the
// default ranks by degree.
const connectedOnPage = unbounded.nodes.filter((n) => /^n_00[0-4]\d$/.test(n.id));
expect(connectedOnPage.length).toBe(50);
});
it("honors limit and offset for paged graph-query traversal", async () => {
for (let i = 0; i < 50; i++) {
const node: GraphNode = {
id: `p_${i.toString().padStart(3, "0")}`,
type: "concept",
name: `node-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
} as GraphNode;
await kv.set("mem:graph:nodes", node.id, node);
}
const page1 = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 0,
})) as GraphQueryResult;
const page2 = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 10,
})) as GraphQueryResult;
expect(page1.nodes.length).toBe(10);
expect(page2.nodes.length).toBe(10);
expect(page1.totalNodes).toBe(50);
expect(page2.totalNodes).toBe(50);
expect(page1.truncated).toBe(true);
// The two pages must not overlap.
const overlap = page1.nodes.filter((n) =>
page2.nodes.some((p) => p.id === n.id),
);
expect(overlap.length).toBe(0);
});
it("clamps an explicit limit above the cap to the cap value", async () => {
for (let i = 0; i < 10; i++) {
await kv.set("mem:graph:nodes", `c_${i}`, {
id: `c_${i}`,
type: "concept",
name: `n-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
});
}
const huge = (await sdk.trigger("mem::graph-query", {
limit: 999999,
})) as GraphQueryResult;
expect(huge.limit).toBeLessThanOrEqual(5000);
expect(huge.nodes.length).toBe(10);
expect(huge.truncated).toBe(false);
});
it("paginate excludes edges whose endpoints fall outside the page", async () => {
for (let i = 0; i < 60; i++) {
await kv.set("mem:graph:nodes", `x_${i.toString().padStart(3, "0")}`, {
id: `x_${i.toString().padStart(3, "0")}`,
type: "concept",
name: `n-${i}`,
properties: {},
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
observationCount: 1,
});
}
// Make the first 10 nodes a tightly connected cluster so they
// rank highest by degree and land on the page deterministically.
for (let i = 0; i < 10; i++) {
const next = (i + 1) % 10;
await kv.set("mem:graph:edges", `cluster_${i}`, {
id: `cluster_${i}`,
type: "related_to",
sourceNodeId: `x_${i.toString().padStart(3, "0")}`,
targetNodeId: `x_${next.toString().padStart(3, "0")}`,
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
});
}
// Cross-page edge: source in the high-degree cluster (on page),
// target is an isolated node (degree 1; cluster nodes have
// degree 2 so the target ranks below the cap).
await kv.set("mem:graph:edges", "cross", {
id: "cross",
type: "related_to",
sourceNodeId: "x_005",
targetNodeId: "x_055",
weight: 1,
evidence: [],
firstSeen: "2026-01-01T00:00:00Z",
lastSeen: "2026-01-01T00:00:00Z",
});
const page = (await sdk.trigger("mem::graph-query", {
limit: 10,
offset: 0,
})) as GraphQueryResult;
// The cross-page edge should not appear in the page response —
// otherwise the viewer renders a dangling line to a node it
// doesn't have.
expect(page.edges.find((e) => e.id === "cross")).toBeUndefined();
// Cluster edges among page nodes ARE present.
expect(page.edges.filter((e) => e.id.startsWith("cluster_")).length).toBe(10);
// totalEdges counts every edge in the full result universe.
expect(page.totalEdges).toBe(11);
});
});