chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Post-#814 the empty-body path reads the snapshot exclusively.
|
||||
// Backfill the snapshot from the seeded data first.
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// #814: precomputed snapshot path. The viewer-tab default-cap query
|
||||
// and graph-stats both have to work at 75K-node scale where the
|
||||
// full kv.list enumeration exceeds the iii invocation budget.
|
||||
describe("snapshot cache (#814)", () => {
|
||||
async function seed(nodeCount: number, edgeCount: number) {
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
await kv.set("mem:graph:nodes", `n_${i}`, {
|
||||
id: `n_${i}`,
|
||||
type: i % 3 === 0 ? "file" : "function",
|
||||
name: `node-${i}`,
|
||||
properties: {},
|
||||
sourceObservationIds: [`obs_${i}`],
|
||||
firstSeen: "2026-01-01T00:00:00Z",
|
||||
lastSeen: "2026-01-01T00:00:00Z",
|
||||
observationCount: 1,
|
||||
stale: false,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < edgeCount; i++) {
|
||||
const src = `n_${i % nodeCount}`;
|
||||
const dst = `n_${(i + 1) % nodeCount}`;
|
||||
await kv.set("mem:graph:edges", `e_${i}`, {
|
||||
id: `e_${i}`,
|
||||
type: i % 2 === 0 ? "uses" : "imports",
|
||||
sourceNodeId: src,
|
||||
targetNodeId: dst,
|
||||
weight: 1,
|
||||
evidence: [],
|
||||
sourceObservationIds: [`obs_${i}`],
|
||||
firstSeen: "2026-01-01T00:00:00Z",
|
||||
lastSeen: "2026-01-01T00:00:00Z",
|
||||
stale: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("snapshot-rebuild persists top-degree subgraph + aggregate stats", async () => {
|
||||
await seed(50, 100);
|
||||
const result = (await sdk.trigger("mem::graph-snapshot-rebuild", { force: true })) as {
|
||||
success: boolean;
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
topNodes: number;
|
||||
topEdges: number;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.totalNodes).toBe(50);
|
||||
expect(result.totalEdges).toBe(100);
|
||||
// 50 nodes is below the SNAPSHOT_TOP_NODES cap, so every node
|
||||
// lands in the snapshot.
|
||||
expect(result.topNodes).toBe(50);
|
||||
|
||||
const snap = await kv.get<{
|
||||
version: number;
|
||||
topNodes: unknown[];
|
||||
stats: { totalNodes: number; nodesByType: Record<string, number> };
|
||||
}>("mem:graph:snapshot", "current");
|
||||
expect(snap).not.toBeNull();
|
||||
expect(snap!.version).toBe(1);
|
||||
expect(snap!.stats.totalNodes).toBe(50);
|
||||
// nodesByType reflects every type seen.
|
||||
expect(snap!.stats.nodesByType["file"]).toBeGreaterThan(0);
|
||||
expect(snap!.stats.nodesByType["function"]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("graph-query empty-body branch serves from snapshot once it exists", async () => {
|
||||
await seed(20, 30);
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
const result = (await sdk.trigger("mem::graph-query", {})) as GraphQueryResult;
|
||||
expect(result.fromSnapshot).toBe(true);
|
||||
expect(result.totalNodes).toBe(20);
|
||||
expect(result.totalEdges).toBe(30);
|
||||
});
|
||||
|
||||
it("graph-query nodeType filter respects snapshot type counts", async () => {
|
||||
await seed(30, 0);
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
const fileQuery = (await sdk.trigger("mem::graph-query", {
|
||||
nodeType: "file",
|
||||
})) as GraphQueryResult;
|
||||
expect(fileQuery.fromSnapshot).toBe(true);
|
||||
// 30 nodes, every 3rd is "file" → 10 files.
|
||||
expect(fileQuery.totalNodes).toBe(10);
|
||||
for (const n of fileQuery.nodes) {
|
||||
expect(n.type).toBe("file");
|
||||
}
|
||||
});
|
||||
|
||||
it("graph-stats returns from snapshot when not dirty", async () => {
|
||||
await seed(15, 25);
|
||||
await sdk.trigger("mem::graph-snapshot-rebuild", { force: true });
|
||||
|
||||
const stats = (await sdk.trigger("mem::graph-stats", {})) as {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
fromSnapshot: boolean;
|
||||
};
|
||||
expect(stats.fromSnapshot).toBe(true);
|
||||
expect(stats.totalNodes).toBe(15);
|
||||
expect(stats.totalEdges).toBe(25);
|
||||
});
|
||||
|
||||
it("graph-extract updates snapshot inline (no kv.list, dirty stays false)", async () => {
|
||||
// Post-#814 v2 the snapshot is updated incrementally on every
|
||||
// extract — no dirty flag bounces. Test asserts that after an
|
||||
// extract the snapshot reflects the new nodes/edges.
|
||||
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
||||
|
||||
const snap = await kv.get<{
|
||||
dirty: boolean;
|
||||
stats: { totalNodes: number };
|
||||
}>("mem:graph:snapshot", "current");
|
||||
expect(snap?.dirty).toBe(false);
|
||||
// testObs produces 2 nodes (src/index.ts, main) + 1 edge.
|
||||
expect(snap?.stats.totalNodes).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("graph-extract maintains name-index for O(1) dedup on re-extract", async () => {
|
||||
// First extract creates nodes.
|
||||
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
||||
const nameIndex = await kv.get<string>(
|
||||
"mem:graph:name-index",
|
||||
"file|src/index.ts",
|
||||
);
|
||||
expect(typeof nameIndex).toBe("string");
|
||||
|
||||
// Re-extract the same observation. With name-index lookup the
|
||||
// existing node merges; no duplicates.
|
||||
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
||||
const nodes = await kv.list<{ name: string; type: string }>(
|
||||
"mem:graph:nodes",
|
||||
);
|
||||
const fileNodes = nodes.filter(
|
||||
(n) => n.name === "src/index.ts" && n.type === "file",
|
||||
);
|
||||
expect(fileNodes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("graph-stats returns empty envelope + warning when no snapshot exists", async () => {
|
||||
// Seed nodes but never rebuild the snapshot — simulates a legacy
|
||||
// corpus on a post-#814 upgrade.
|
||||
await seed(5, 5);
|
||||
|
||||
const stats = (await sdk.trigger("mem::graph-stats", {})) as {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
fromSnapshot: boolean;
|
||||
warning?: string;
|
||||
};
|
||||
expect(stats.fromSnapshot).toBe(false);
|
||||
expect(stats.totalNodes).toBe(0);
|
||||
expect(stats.warning).toMatch(/snapshot-rebuild|graph\/reset/);
|
||||
});
|
||||
|
||||
it("graph-reset clears state and writes empty snapshot", async () => {
|
||||
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
||||
const result = (await sdk.trigger("mem::graph-reset", {})) as {
|
||||
success: boolean;
|
||||
cleared: Record<string, number>;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const snap = await kv.get<{
|
||||
stats: { totalNodes: number };
|
||||
}>("mem:graph:snapshot", "current");
|
||||
expect(snap?.stats.totalNodes).toBe(0);
|
||||
});
|
||||
|
||||
it("graph-reset writes empty snapshot; legacy rows stay as orphans (#825)", async () => {
|
||||
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
||||
// Index entries exist after the extract.
|
||||
const nameBefore = await kv.get(
|
||||
"mem:graph:name-index",
|
||||
"file|src/index.ts",
|
||||
);
|
||||
expect(nameBefore).not.toBeNull();
|
||||
|
||||
await sdk.trigger("mem::graph-reset", {});
|
||||
|
||||
// Post-#825: reset is enumeration-free. It writes an empty
|
||||
// snapshot; the legacy index rows remain on disk as orphans
|
||||
// but are never read by any post-#816 code path (hot path
|
||||
// reads only the snapshot, which is now empty). Asserting the
|
||||
// visible behavior: snapshot empty, hot path returns empty.
|
||||
const snap = await kv.get<{
|
||||
stats: { totalNodes: number; totalEdges: number };
|
||||
}>("mem:graph:snapshot", "current");
|
||||
expect(snap?.stats.totalNodes).toBe(0);
|
||||
expect(snap?.stats.totalEdges).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// CodeRabbit feedback: cover the timeout-budget fallback path and
|
||||
// the oversized-corpus rebuild refusal. The hot path never enumerates
|
||||
// any more, but the rebuild endpoint AND the BFS / query branches
|
||||
// still call kv.list — both need explicit failure-mode tests.
|
||||
describe("budget + tooLarge guards (#814 v2)", () => {
|
||||
function slowKV(delayMs: number) {
|
||||
const base = mockKV();
|
||||
return {
|
||||
...base,
|
||||
list: async <T>(scope: string): Promise<T[]> => {
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
return base.list<T>(scope);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("graph-query startNodeId returns warning envelope when enumeration exceeds budget", async () => {
|
||||
const slow = slowKV(7000); // > LIVE_ENUMERATION_BUDGET_MS (6000ms)
|
||||
const localSdk = mockSdk();
|
||||
registerGraphFunction(localSdk as never, slow as never, mockProvider as never);
|
||||
|
||||
const result = (await localSdk.trigger("mem::graph-query", {
|
||||
startNodeId: "n_missing",
|
||||
})) as GraphQueryResult;
|
||||
|
||||
expect(result.warning).toBeTruthy();
|
||||
expect(result.warning).toMatch(/budget|enumeration/i);
|
||||
}, 10000);
|
||||
|
||||
// CodeRabbit raised that slowKV(setTimeout) doesn't simulate a
|
||||
// blocked event loop. The real production failure is iii rejecting
|
||||
// the trigger with "Invocation stopped" after the worker dies
|
||||
// (heartbeat starvation). A rejecting kv.list mock covers that
|
||||
// catch-path directly without introducing a busy-wait that would
|
||||
// also starve the budget timer and produce a flaky test.
|
||||
function rejectingKV() {
|
||||
const base = mockKV();
|
||||
return {
|
||||
...base,
|
||||
list: async <T>(_scope: string): Promise<T[]> => {
|
||||
throw new Error("Invocation stopped");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("graph-query rejects-from-engine path returns warning envelope (worker-death simulation)", async () => {
|
||||
const rejector = rejectingKV();
|
||||
const localSdk = mockSdk();
|
||||
registerGraphFunction(
|
||||
localSdk as never,
|
||||
rejector as never,
|
||||
mockProvider as never,
|
||||
);
|
||||
|
||||
const result = (await localSdk.trigger("mem::graph-query", {
|
||||
startNodeId: "n_missing",
|
||||
})) as GraphQueryResult;
|
||||
|
||||
expect(result.warning).toBeTruthy();
|
||||
expect(result.nodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("graph-snapshot-rebuild refuses corpora past REBUILD_SAFE_NODE_CEILING", async () => {
|
||||
// Direct-poke the mock store with > 25K node values so kv.list
|
||||
// returns them without paying the per-set cost. Each node only
|
||||
// needs id/type/name/stale=false for the rebuild path.
|
||||
const localKv = mockKV();
|
||||
// Walk the implementation detail: mockKV stores entries in a
|
||||
// Map under the scope key. Push directly to that map via the
|
||||
// public `set` API in a tight loop.
|
||||
const COUNT = 25001;
|
||||
const sets: Array<Promise<unknown>> = [];
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
sets.push(
|
||||
localKv.set("mem:graph:nodes", `bn_${i}`, {
|
||||
id: `bn_${i}`,
|
||||
type: "concept",
|
||||
name: `bulk-${i}`,
|
||||
properties: {},
|
||||
sourceObservationIds: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
stale: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(sets);
|
||||
|
||||
const localSdk = mockSdk();
|
||||
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
|
||||
|
||||
const result = (await localSdk.trigger(
|
||||
"mem::graph-snapshot-rebuild",
|
||||
{ force: true },
|
||||
)) as { success: boolean; tooLarge?: boolean; totalNodes?: number };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.tooLarge).toBe(true);
|
||||
expect(result.totalNodes).toBeGreaterThanOrEqual(25001);
|
||||
});
|
||||
|
||||
// #825: new pre-flight refusal when no snapshot exists (signals
|
||||
// legacy corpus that would crash on kv.list). force=true bypasses.
|
||||
it("graph-snapshot-rebuild refuses on legacy corpus (no snapshot) without force", async () => {
|
||||
const localKv = mockKV();
|
||||
// Seed nodes but never persist a snapshot → simulates a corpus
|
||||
// built on a pre-#814 agentmemory.
|
||||
await localKv.set("mem:graph:nodes", "legacy_n", {
|
||||
id: "legacy_n",
|
||||
type: "concept",
|
||||
name: "legacy",
|
||||
properties: {},
|
||||
sourceObservationIds: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
stale: false,
|
||||
});
|
||||
const localSdk = mockSdk();
|
||||
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
|
||||
|
||||
const result = (await localSdk.trigger(
|
||||
"mem::graph-snapshot-rebuild",
|
||||
{},
|
||||
)) as { success: boolean; legacyCorpus?: boolean; error?: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.legacyCorpus).toBe(true);
|
||||
expect(result.error).toMatch(/graph\/reset|force/);
|
||||
});
|
||||
|
||||
it("graph-reset is enumeration-free (does not call kv.list)", async () => {
|
||||
// Wrap the mock kv.list with a counter; assert it stays at 0
|
||||
// across a full reset cycle.
|
||||
const localKv = mockKV();
|
||||
let listCalls = 0;
|
||||
const baseList = localKv.list;
|
||||
localKv.list = async <T,>(scope: string): Promise<T[]> => {
|
||||
listCalls += 1;
|
||||
return baseList.call(localKv, scope) as Promise<T[]>;
|
||||
};
|
||||
const localSdk = mockSdk();
|
||||
registerGraphFunction(localSdk as never, localKv as never, mockProvider as never);
|
||||
|
||||
const result = (await localSdk.trigger("mem::graph-reset", {})) as {
|
||||
success: boolean;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
expect(listCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user