The tools_fails_2 diagnosis (glm-5.3 dropping trailing required fields):
- probe-phase quiz/explain renamed to probe/recall — a shared name carried
the probe call shape into teaching (the first teach quiz dropped nodeId);
display seams accept the old names forever
- teach quiz+explain schemas lead with the required pair (type, nodeId);
explain gains the predict/check type and joins the reveal law (checks
collapse, predictions arrive open; no-type records render check-style)
- plan_init/probe_done results go context-slim (owner, context diet):
content = validated nodeIds + binding-id reminder / fixed ack; details
keeps the tree for the popup and {ok:true} — serializer-verified that
details never reaches the model; failure echoes dropped
- the cards carry the tool's own name (Probe/Recall) and the quiz type
reads on the label (Prediction/Check) in composer and transcript
231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
// THE PROBER'S GRAPH TOOLS (wave 7; understanding levels removed
|
|
// 2026-09-27) — pinned behaviors:
|
|
// 1. REGISTRATION: the factory registers list_sessions/list_propositions
|
|
// on the prober ONLY when the composition root passes a graphStore
|
|
// (the graphToolsEnabled gate lives server-side; absent store = closed
|
|
// gate — the factory must not reach for settings itself).
|
|
// 2. OUTPUT: names only — the owner ruling bars statement text and session
|
|
// contents from the prober's view of the map.
|
|
// 3. HONESTY: empty graph, unknown session id — the tools answer in
|
|
// their own voice, never throw, never guess.
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { createProberTools } from "../src/agent/tools.ts";
|
|
import { auditorGraphTools } from "../src/agent/tools/graph.ts";
|
|
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
|
|
import type { ChatSummary } from "../src/core/contracts/chat.ts";
|
|
import type { GraphChain, GraphNode } from "../src/core/contracts/graph.ts";
|
|
|
|
// ─── fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
|
|
const node = (
|
|
id: string,
|
|
name: string,
|
|
provenance: Array<{ chatId: string; messageId: string }>,
|
|
): GraphNode => ({
|
|
id,
|
|
name,
|
|
statement: `statement of ${name}`, // must NEVER leak into tool output
|
|
aliases: [],
|
|
createdAt: "2026-09-23T00:00:00.000Z",
|
|
provenance,
|
|
});
|
|
|
|
function fakeStores(
|
|
nodes: GraphNode[],
|
|
over: {
|
|
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
|
|
} = {},
|
|
): { chatStore: ChatStore; graphStore: GraphStore } {
|
|
const chains: GraphChain[] = [];
|
|
const graphStore: GraphStore = {
|
|
readNodes: () => nodes,
|
|
readChains: () => chains,
|
|
readCheckpoints: () => [],
|
|
readCoverage: () => [],
|
|
readSources: () => [],
|
|
readPositions: () => ({}),
|
|
writePositions: () => {},
|
|
readClusterLabels: () => [],
|
|
appendClusterLabel: () => {},
|
|
appendNamingRun: () => {},
|
|
} as unknown as GraphStore;
|
|
const chatStore: ChatStore = {
|
|
listChats: () =>
|
|
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
|
|
getChat: (id: string) =>
|
|
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
|
|
} as unknown as ChatStore;
|
|
return { chatStore, graphStore };
|
|
}
|
|
|
|
const stubJobStore = {
|
|
createJob: (() => ({})) as never,
|
|
getJob: (() => undefined) as never,
|
|
listJobs: (() => []) as never,
|
|
updateJob: (() => {}) as never,
|
|
} as unknown as JobStore;
|
|
|
|
const baseCtx = {
|
|
chatId: "c1",
|
|
deliver: () => {},
|
|
jobStore: stubJobStore,
|
|
spawn: () => {},
|
|
};
|
|
|
|
// ─── registration (the factory gate) ────────────────────────────────────────
|
|
|
|
describe("prober graph tools — registration", () => {
|
|
it("no graphStore in the context → the tools are absent (gate decided server-side)", () => {
|
|
const { chatStore } = fakeStores([]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore });
|
|
assert.equal("list_sessions" in tools, false);
|
|
assert.equal("list_propositions" in tools, false);
|
|
});
|
|
|
|
it("graphStore present → list_sessions + list_propositions join the registry beside the usual set", () => {
|
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
// The standing set (fix wave 2026-09-20: research + pdf are
|
|
// phase-omnipresent) plus the two graph-map readers.
|
|
assert.deepEqual(Object.keys(tools).sort(), [
|
|
"ask_user_question",
|
|
"find",
|
|
"grep",
|
|
"list_propositions",
|
|
"list_sessions",
|
|
"ls",
|
|
"pdf",
|
|
"probe",
|
|
"probe_done",
|
|
"read",
|
|
"research",
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── output shape + the no-prose rule ──────────────────────────────────────
|
|
|
|
describe("list_sessions", () => {
|
|
it("names, kinds, counts, dates, ids — in the prober's voice, no statements", async () => {
|
|
const { chatStore, graphStore } = fakeStores(
|
|
[
|
|
node("n1", "Water cycle", [pt("c1")]),
|
|
node("n2", "Evaporation", [pt("c1")]),
|
|
node("n3", "Nietzsche", [pt("c-file")]),
|
|
],
|
|
{
|
|
chats: [
|
|
{
|
|
id: "c1",
|
|
title: "First lesson",
|
|
createdAt: "2026-09-20T10:00:00.000Z",
|
|
updatedAt: "2026-09-21T10:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
|
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
|
|
// The chat: name, kind, born count, dates, id — the full resume.
|
|
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
|
|
// The unrecorded birth session falls back honestly: the id, no fake date.
|
|
assert.match(out, /c-file \(file, 1 proposition, born unknown date\) — id: c-file/);
|
|
// The owner ruling: the map's prose never reaches the prober.
|
|
assert.ok(!out.includes("statement of"));
|
|
});
|
|
|
|
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
|
|
const { chatStore, graphStore } = fakeStores([]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
|
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
|
|
});
|
|
});
|
|
|
|
describe("list_propositions", () => {
|
|
it("names only — what the session covered (2026-09-27: no levels, no dating)", async () => {
|
|
const { chatStore, graphStore } = fakeStores([
|
|
node("n1", "Water cycle", [pt("c1")]),
|
|
node("n2", "Evaporation", [pt("c1")]),
|
|
]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
|
assert.match(out, /2 proposition\(s\) covered in this session:/);
|
|
assert.match(out, /- Water cycle/);
|
|
assert.match(out, /- Evaporation/);
|
|
// The owner ruling: no levels, no dating, never the statement text.
|
|
assert.ok(!out.includes("as of"));
|
|
assert.ok(!out.includes("solid"));
|
|
assert.ok(!out.includes("statement of"));
|
|
});
|
|
|
|
it("no evaluation machinery at all — the covered list is the whole output", async () => {
|
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
|
assert.match(out, /- Water cycle/);
|
|
assert.ok(!out.includes("never evaluated"));
|
|
});
|
|
|
|
it("an unknown session id — honest redirect, not a throw", async () => {
|
|
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
|
|
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
|
|
const out = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
|
|
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
|
});
|
|
});
|
|
|
|
// ─── the auditor's toolset (2026-09-27 relink) ───────────────────────────────
|
|
|
|
describe("auditor graph tools", () => {
|
|
const nodes = [
|
|
{ ...node("n1", "Will to power", [pt("c1")]), statement: "The will to power is Nietzsche's central drive.", aliases: ["power drive"] },
|
|
{ ...node("n2", "Eternal recurrence", [pt("c1")]), statement: "Eternal recurrence is the thought experiment of living again.", aliases: [] },
|
|
];
|
|
const { chatStore, graphStore } = fakeStores(nodes);
|
|
|
|
it("find: glob over names + statements + aliases, returning id + name + statement + BIRTH SESSION (owner, 2026-09-29)", async () => {
|
|
const tools = auditorGraphTools({ chatStore, graphStore });
|
|
const out = (await tools.find!.execute!("tc", { query: "*will to power*" })).details as string;
|
|
assert.match(out, /1 match\(es\):/);
|
|
assert.match(out, /- n1 :: Will to power/);
|
|
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
|
|
// The born-in pointer: hits compose with list_propositions.
|
|
assert.match(out, /born in: c1/);
|
|
// Statements and aliases hit too — and a bare term gets implicit stars.
|
|
const byStatement = (await tools.find!.execute!("tc", { query: "living again" })).details as string;
|
|
assert.match(byStatement, /n2 :: Eternal recurrence/);
|
|
const byAlias = (await tools.find!.execute!("tc", { query: "power drive" })).details as string;
|
|
assert.match(byAlias, /n1/);
|
|
const miss = (await tools.find!.execute!("tc", { query: "*nothing matches this*" })).details as string;
|
|
assert.equal(miss, 'No existing proposition matches "*nothing matches this*".');
|
|
});
|
|
|
|
it("list_sessions: names, kinds, counts, ids — NO timestamps (identity work needs content, not chronology)", async () => {
|
|
const { chatStore: cs, graphStore: gs } = fakeStores(nodes, {
|
|
chats: [{ id: "c1", title: "Nietzsche seminar", createdAt: "2026-09-20T10:00:00.000Z", updatedAt: "2026-09-21T10:00:00.000Z" }],
|
|
});
|
|
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
|
|
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
|
|
assert.match(out, /1 session\(s\):/);
|
|
assert.match(out, /Nietzsche seminar \(chat, 2 propositions\) — id: c1/);
|
|
assert.ok(!out.includes("2026-09-20"), "no dates in the auditor's view");
|
|
});
|
|
|
|
it("list_propositions: FULL statement text (identity work), no dates", async () => {
|
|
const { chatStore: cs, graphStore: gs } = fakeStores(nodes);
|
|
const tools = auditorGraphTools({ chatStore: cs, graphStore: gs });
|
|
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
|
|
assert.match(out, /2 proposition\(s\) born in this session:/);
|
|
assert.match(out, /- n1 :: Will to power/);
|
|
assert.match(out, /statement: The will to power is Nietzsche's central drive\./);
|
|
assert.ok(!out.includes("2026-09"), "no dates in the auditor's view");
|
|
const miss = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
|
|
assert.equal(miss, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
|
|
});
|
|
});
|